feat: 迁入 MiGu.Server、平台前端与车辆列表 reflection 回退
从 Simple-FR 拆出 Platform.Server 并重命名为 MiGu.Server;frontends 源码与构建脚本迁入本仓库。地图监控在 projection/cars 失败或为空时回退 reflection 车辆列表;Simple 仓库已移除旧 Platform.Server。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
data/*.json
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server ↔ SimpleLite 8222 之间的 **内部共享 token**。
|
||||
///
|
||||
/// 设计动机:SimpleLite 8222 的 EmbedIO WebApi(ReflectionApi / PersistenceApi / MapEditApi)
|
||||
/// 历史上完全无鉴权,远程访问 = 接管调度内核。为不在 SimpleLite 侧实现完整 JWT 验签
|
||||
/// (减少 SimpleLite 复杂度),采用一个轻量约定:
|
||||
///
|
||||
/// - <b>本机回环</b>(127.0.0.1 / ::1)SimpleLite 直接放行(开发机直连不受影响);
|
||||
/// - <b>其它来源</b>必须携带 <c>X-Platform-Internal-Token</c> header;
|
||||
/// - MiGu.Server YARP 反代 <c>/api/sl/*</c> 到 SimpleLite 8222 时,YARP transform
|
||||
/// 会自动追加该 header;
|
||||
/// - SimpleLite 端通过 <c>simple.json:platform.internalToken</c> 或环境变量
|
||||
/// <c>SIMPLELITE__PLATFORM__INTERNALTOKEN</c> 配置同一个 token;两端不一致即拒绝。
|
||||
///
|
||||
/// Token 来源优先级:
|
||||
/// 1) appsettings.json:Internal:Token 显式配置(生产推荐:strong, length ≥ 32)
|
||||
/// 2) 环境变量 PLATFORM__INTERNAL__TOKEN
|
||||
/// 3) 兜底:进程随机 64 字节 base64,并在日志告警 + 写入 <c>data/.internal-token</c>
|
||||
/// 文件供本机 SimpleLite 读取(同机部署常见场景)
|
||||
/// </summary>
|
||||
public sealed class InternalTokenStore
|
||||
{
|
||||
public string Token { get; }
|
||||
public bool IsEphemeral { get; }
|
||||
public string? PersistedFilePath { get; }
|
||||
|
||||
public InternalTokenStore(IConfiguration config, IWebHostEnvironment env, ILogger<InternalTokenStore> logger)
|
||||
{
|
||||
var configured = config["Internal:Token"];
|
||||
if (!string.IsNullOrWhiteSpace(configured) && configured != "REPLACE_ME")
|
||||
{
|
||||
Token = configured;
|
||||
IsEphemeral = false;
|
||||
logger.LogInformation("Internal token 从配置读取(长度 {Len})。", Token.Length);
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底:进程随机 + 落地到 data/.internal-token 便于同机 SimpleLite 读取
|
||||
var dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
var file = Path.Combine(dataDir, ".internal-token");
|
||||
if (File.Exists(file))
|
||||
{
|
||||
try
|
||||
{
|
||||
var existing = File.ReadAllText(file).Trim();
|
||||
if (existing.Length >= 32)
|
||||
{
|
||||
Token = existing;
|
||||
IsEphemeral = false;
|
||||
PersistedFilePath = file;
|
||||
logger.LogInformation("Internal token 复用 {File}(长度 {Len})。", file, Token.Length);
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "读取 {File} 失败,将重新生成 internal token。", file);
|
||||
}
|
||||
}
|
||||
|
||||
Token = Convert.ToBase64String(RandomNumberGenerator.GetBytes(48));
|
||||
IsEphemeral = true;
|
||||
try
|
||||
{
|
||||
File.WriteAllText(file, Token);
|
||||
PersistedFilePath = file;
|
||||
logger.LogWarning(
|
||||
"Internal token 未配置 —— 已生成进程随机值并写入 {File}(重启后保留)。" +
|
||||
"若 MiGu.Server 与 SimpleLite 不在同一台机器,请把同一个值写入 SimpleLite 端 simple.json:platform.internalToken。",
|
||||
file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "internal token 落盘失败 —— 远程 SimpleLite 调用将无法通过鉴权(每次重启 token 都不同)。");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 平台 JWT 颁发与验签。Secret 从 <c>appsettings.json</c> 的 <c>Jwt:Secret</c> 读取;
|
||||
/// 若为占位值 <c>"REPLACE_ME"</c> 则启动期生成临时随机 secret 并在日志里强制告警,
|
||||
/// 防止生产部署忘改 secret 导致历史 hardcoded "simple-mock-secret" 风险复现。
|
||||
/// </summary>
|
||||
public sealed class JwtIssuer
|
||||
{
|
||||
/// <summary>占位 secret;启动期检测到时自动换成进程随机值,并在日志里 critical 告警。</summary>
|
||||
public const string PlaceholderSecret = "REPLACE_ME";
|
||||
|
||||
public string Issuer { get; }
|
||||
public string Audience { get; }
|
||||
public TimeSpan Lifetime { get; }
|
||||
public string SecretInUse { get; }
|
||||
public bool SecretIsEphemeral { get; }
|
||||
|
||||
private readonly SymmetricSecurityKey _key;
|
||||
private readonly SigningCredentials _signing;
|
||||
private readonly TokenValidationParameters _validation;
|
||||
|
||||
public JwtIssuer(string secret, string issuer, string audience, TimeSpan lifetime, ILogger<JwtIssuer> logger)
|
||||
{
|
||||
Issuer = issuer;
|
||||
Audience = audience;
|
||||
Lifetime = lifetime;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secret) || secret == PlaceholderSecret)
|
||||
{
|
||||
// 兜底:用进程随机 secret,保证签名不被预知,但 token 在 MiGu.Server 重启后失效。
|
||||
// 这条路径只应出现在「开发机首次启动」/「忘改配置的部署」,**日志里强制告警让运维注意**。
|
||||
secret = RandomBase64Secret(64);
|
||||
SecretIsEphemeral = true;
|
||||
logger.LogCritical(
|
||||
"Jwt:Secret 是占位值或未配置 —— 已生成进程随机 secret(重启后所有 token 失效)。" +
|
||||
"生产环境必须在 appsettings.Production.json 或环境变量 PLATFORM__JWT__SECRET 配置一个 ≥ 32 字节的稳定 secret。");
|
||||
}
|
||||
else if (Encoding.UTF8.GetByteCount(secret) < 32)
|
||||
{
|
||||
logger.LogWarning("Jwt:Secret 长度不足 32 字节,HS256 推荐 ≥ 32 字节 secret 以达到 256bit 强度。");
|
||||
}
|
||||
|
||||
SecretInUse = secret;
|
||||
_key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret));
|
||||
_signing = new SigningCredentials(_key, SecurityAlgorithms.HmacSha256);
|
||||
_validation = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidateLifetime = true,
|
||||
ValidIssuer = Issuer,
|
||||
ValidAudience = Audience,
|
||||
IssuerSigningKey = _key,
|
||||
ClockSkew = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>颁发 access token。<paramref name="ops"/> 写入私有 claim <c>ops</c>(空格分隔,便于后端 <c>[Authorize]</c> policy 解析)。</summary>
|
||||
public string Issue(string userId, string username, string scope, IReadOnlyList<string> roles, IReadOnlyList<string> ops)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
new(JwtRegisteredClaimNames.Sub, userId),
|
||||
new(JwtRegisteredClaimNames.UniqueName, username),
|
||||
new("scope", scope),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString("N")),
|
||||
new(JwtRegisteredClaimNames.Iat, new DateTimeOffset(now).ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64),
|
||||
new("ops", string.Join(' ', ops ?? Array.Empty<string>())),
|
||||
};
|
||||
foreach (var r in roles ?? Array.Empty<string>())
|
||||
claims.Add(new Claim(ClaimTypes.Role, r));
|
||||
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: Issuer,
|
||||
audience: Audience,
|
||||
claims: claims,
|
||||
notBefore: now,
|
||||
expires: now.Add(Lifetime),
|
||||
signingCredentials: _signing);
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
/// <summary>给框架的 JwtBearer middleware 用的验证参数(启动期注入到 AddJwtBearer)。</summary>
|
||||
public TokenValidationParameters BuildValidationParameters() => _validation;
|
||||
|
||||
private static string RandomBase64Secret(int byteLen)
|
||||
{
|
||||
var buf = RandomNumberGenerator.GetBytes(byteLen);
|
||||
return Convert.ToBase64String(buf);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace MiGu.Server.Auth;
|
||||
|
||||
/// <summary>
|
||||
/// 最简化的用户表(占位实现):内置 admin / ops 两个账号 + PBKDF2 哈希密码校验。
|
||||
/// 真实生产应替换成 Microsoft.AspNetCore.Identity 或外接 LDAP / OAuth。
|
||||
///
|
||||
/// 安全要点(哪怕是占位也要做到):
|
||||
/// - 密码不明文存储,启动期用 PBKDF2-SHA256(100k iter, 16B salt) 哈希;
|
||||
/// - 密码 hash 比较走 <see cref="CryptographicOperations.FixedTimeEquals"/> 防时序攻击;
|
||||
/// - 不允许「空用户名 = 空密码」之类的快捷绕过。
|
||||
///
|
||||
/// 默认账号:
|
||||
/// admin / admin (Platform scope, role-admin)
|
||||
/// ops / ops (RCSMonitor scope, role-ops)
|
||||
/// 默认密码同名是为了**开发机一次启动就能登录**;生产部署务必通过环境变量
|
||||
/// <c>PLATFORM__AUTH__USERS__<USERNAME>__PASSWORD</c> 改写或接入真实身份源。
|
||||
/// </summary>
|
||||
public sealed class UserStore
|
||||
{
|
||||
public sealed record UserRecord(
|
||||
string Id,
|
||||
string Username,
|
||||
string DisplayName,
|
||||
string DefaultScope,
|
||||
IReadOnlyList<string> Roles,
|
||||
byte[] Salt,
|
||||
byte[] PasswordHash);
|
||||
|
||||
private readonly Dictionary<string, UserRecord> _users;
|
||||
|
||||
public UserStore(IConfiguration config, ILogger<UserStore> logger)
|
||||
{
|
||||
_users = new Dictionary<string, UserRecord>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// 1. 内置 admin / ops(密码可从 appsettings 覆盖)
|
||||
var adminPwd = config["Auth:Users:admin:Password"] ?? "admin";
|
||||
var opsPwd = config["Auth:Users:ops:Password"] ?? "ops";
|
||||
|
||||
Add("u-admin", "admin", "系统管理员", "Platform", new[] { "role-admin", "role-platform-write" }, adminPwd);
|
||||
Add("u-ops", "ops", "运营人员", "RCSMonitor", new[] { "role-ops", "role-monitor-read" }, opsPwd);
|
||||
|
||||
if (adminPwd == "admin" || opsPwd == "ops")
|
||||
{
|
||||
logger.LogWarning(
|
||||
"UserStore 使用默认弱密码(admin/admin 或 ops/ops)。生产环境务必通过 appsettings.Production.json " +
|
||||
"或环境变量 PLATFORM__AUTH__USERS__admin__PASSWORD 等覆盖。");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>用户名 / 密码校验。返回 null = 不存在或密码错。<b>不向调用方区分两种失败原因</b>,防用户名枚举。</summary>
|
||||
public UserRecord? Verify(string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrEmpty(password)) return null;
|
||||
if (!_users.TryGetValue(username, out var u)) return null;
|
||||
|
||||
var hash = Pbkdf2(password, u.Salt);
|
||||
return CryptographicOperations.FixedTimeEquals(hash, u.PasswordHash) ? u : null;
|
||||
}
|
||||
|
||||
public UserRecord? Find(string username) =>
|
||||
_users.TryGetValue(username ?? "", out var u) ? u : null;
|
||||
|
||||
private void Add(string id, string username, string displayName, string defaultScope, IReadOnlyList<string> roles, string plainPassword)
|
||||
{
|
||||
var salt = RandomNumberGenerator.GetBytes(16);
|
||||
var hash = Pbkdf2(plainPassword, salt);
|
||||
_users[username] = new UserRecord(id, username, displayName, defaultScope, roles, salt, hash);
|
||||
}
|
||||
|
||||
private static byte[] Pbkdf2(string password, byte[] salt) =>
|
||||
Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations: 100_000, HashAlgorithmName.SHA256, 32);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record ChargePriorityRule(string Id, string Condition, double Weight);
|
||||
|
||||
public record ChargePolicy(
|
||||
bool AllowMidTaskCharge,
|
||||
int IdleChargeAfterSec,
|
||||
List<ChargePriorityRule> Priority)
|
||||
{
|
||||
public static ChargePolicy Default() => new(
|
||||
AllowMidTaskCharge: false,
|
||||
IdleChargeAfterSec: 300,
|
||||
Priority: new List<ChargePriorityRule>
|
||||
{
|
||||
new("CP1", "soc<0.2", 100),
|
||||
new("CP2", "idle>5min", 30)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
/// <summary>
|
||||
/// 配置中心存储(内存 + JSON 文件持久化占位)。
|
||||
/// 14 个 section 对应 ARCHITECTURE.md §9 的 13+1 维度。
|
||||
/// 真实落地时由 SimpleShared.Persistence 接入 EF Core,并配合 YARP 下发至 SimpleLite。
|
||||
/// </summary>
|
||||
public sealed class ConfigStore
|
||||
{
|
||||
public static readonly string[] AllSections =
|
||||
{
|
||||
"system", "integrations", "routing", "vehicle", "charge", "task",
|
||||
"traffic", "auth", "device", "fleet", "scenario", "location", "ops", "widget"
|
||||
};
|
||||
|
||||
public sealed record Envelope(string Section, int Version, DateTimeOffset UpdatedAt, object Payload);
|
||||
|
||||
private readonly ConcurrentDictionary<string, Envelope> _mem = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly string _dataDir;
|
||||
private readonly ILogger<ConfigStore> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true
|
||||
};
|
||||
|
||||
public ConfigStore(IWebHostEnvironment env, ILogger<ConfigStore> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_dataDir = Path.Combine(env.ContentRootPath, "data");
|
||||
Directory.CreateDirectory(_dataDir);
|
||||
SeedAndLoad();
|
||||
}
|
||||
|
||||
public Envelope Get(string section)
|
||||
{
|
||||
section = section.ToLowerInvariant();
|
||||
if (_mem.TryGetValue(section, out var env))
|
||||
{
|
||||
if (section == "ops") return MergeOpsEnvelope(env);
|
||||
return env;
|
||||
}
|
||||
var def = NewDefault(section);
|
||||
_mem[section] = def;
|
||||
Persist(def);
|
||||
return def;
|
||||
}
|
||||
|
||||
public Envelope Put(string section, JsonElement payload)
|
||||
{
|
||||
section = section.ToLowerInvariant();
|
||||
if (!AllSections.Contains(section))
|
||||
throw new ArgumentException($"未知 section: {section}");
|
||||
|
||||
var prev = _mem.TryGetValue(section, out var p) ? p : null;
|
||||
var version = (prev?.Version ?? 0) + 1;
|
||||
// payload 保留 JsonElement 原样;序列化器会按 camelCase 输出
|
||||
var env = new Envelope(section, version, DateTimeOffset.UtcNow, JsonElementToObject(payload));
|
||||
if (section == "ops") env = MergeOpsEnvelope(env);
|
||||
_mem[section] = env;
|
||||
Persist(env);
|
||||
return env;
|
||||
}
|
||||
|
||||
public IEnumerable<Envelope> List() => AllSections.Select(Get);
|
||||
|
||||
private static object JsonElementToObject(JsonElement el)
|
||||
{
|
||||
// 简化:直接保留 JsonElement,让 STJ 在响应时再原样写回
|
||||
return el.Clone();
|
||||
}
|
||||
|
||||
private void SeedAndLoad()
|
||||
{
|
||||
foreach (var s in AllSections)
|
||||
{
|
||||
var file = FilePath(s);
|
||||
if (File.Exists(file))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(file);
|
||||
var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var version = root.TryGetProperty("version", out var v) && v.ValueKind == JsonValueKind.Number
|
||||
? v.GetInt32() : 1;
|
||||
var updated = root.TryGetProperty("updatedAt", out var u) && u.ValueKind == JsonValueKind.String
|
||||
? DateTimeOffset.Parse(u.GetString()!) : DateTimeOffset.UtcNow;
|
||||
var payload = root.TryGetProperty("payload", out var pl)
|
||||
? (object)pl.Clone()
|
||||
: DefaultPayload(s);
|
||||
_mem[s] = new Envelope(s, version, updated, payload);
|
||||
continue;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "加载 {Section} 失败,回退到默认值", s);
|
||||
}
|
||||
}
|
||||
_mem[s] = NewDefault(s);
|
||||
Persist(_mem[s]);
|
||||
}
|
||||
}
|
||||
|
||||
private Envelope NewDefault(string section)
|
||||
{
|
||||
return new Envelope(section, 1, DateTimeOffset.UtcNow, DefaultPayload(section));
|
||||
}
|
||||
|
||||
private static object DefaultPayload(string section) => section switch
|
||||
{
|
||||
"system" => SystemConfig.Default(),
|
||||
"integrations" => ExternalIntegrations.Default(),
|
||||
"routing" => RoutingPolicy.Default(),
|
||||
"vehicle" => VehicleMaintenancePolicy.Default(),
|
||||
"charge" => ChargePolicy.Default(),
|
||||
"task" => TaskAllocationPolicy.Default(),
|
||||
"traffic" => TrafficRule.Default(),
|
||||
"auth" => AuthRoleConfig.Default(),
|
||||
"device" => DeviceManagementConfig.Default(),
|
||||
"fleet" => FleetLifecycleConfig.Default(),
|
||||
"scenario" => ScenarioTemplateConfig.Default(),
|
||||
"location" => LocationManagement.Default(),
|
||||
"ops" => OpsConfig.Default(),
|
||||
"widget" => CustomWidgetConfig.Default(),
|
||||
_ => new { }
|
||||
};
|
||||
|
||||
private void Persist(Envelope env)
|
||||
{
|
||||
try
|
||||
{
|
||||
var toWrite = env.Section == "ops" ? MergeOpsEnvelope(env) : env;
|
||||
var root = new Dictionary<string, object?>
|
||||
{
|
||||
["section"] = toWrite.Section,
|
||||
["version"] = toWrite.Version,
|
||||
["updatedAt"] = toWrite.UpdatedAt,
|
||||
["payload"] = toWrite.Payload
|
||||
};
|
||||
var json = JsonSerializer.Serialize(root, _jsonOpts);
|
||||
File.WriteAllText(FilePath(env.Section), json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "持久化 {Section} 失败", env.Section);
|
||||
}
|
||||
}
|
||||
|
||||
private static Envelope MergeOpsEnvelope(Envelope env)
|
||||
{
|
||||
if (env.Payload is not JsonElement el || el.ValueKind != JsonValueKind.Object)
|
||||
return env;
|
||||
|
||||
var def = OpsConfig.Default();
|
||||
var merged = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
playback = ReadSection(el, "playback") ?? def.Playback,
|
||||
logRetention = ReadSection(el, "logRetention") ?? def.LogRetention,
|
||||
version = ReadSection(el, "version") ?? def.Version,
|
||||
monitor = MergeOpsMonitor(el, def.Monitor)
|
||||
}, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
|
||||
|
||||
return env with { Payload = merged.Clone() };
|
||||
}
|
||||
|
||||
private static object? ReadSection(JsonElement root, string name) =>
|
||||
root.TryGetProperty(name, out var p) ? p.Clone() : null;
|
||||
|
||||
private static JsonElement MergeOpsMonitor(JsonElement root, MonitorOpsPolicy def)
|
||||
{
|
||||
var opts = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
|
||||
if (!root.TryGetProperty("monitor", out var m) || m.ValueKind != JsonValueKind.Object)
|
||||
return JsonSerializer.SerializeToElement(def, opts);
|
||||
|
||||
var car = m.TryGetProperty("car", out var c) ? c.Clone() : JsonSerializer.SerializeToElement(def.Car, opts);
|
||||
var site = m.TryGetProperty("site", out var s) ? s.Clone() : JsonSerializer.SerializeToElement(def.Site, opts);
|
||||
var track = m.TryGetProperty("track", out var t) ? t.Clone() : JsonSerializer.SerializeToElement(def.Track, opts);
|
||||
var carActionByType = m.TryGetProperty("carActionByType", out var cat)
|
||||
? cat.Clone()
|
||||
: JsonSerializer.SerializeToElement(def.CarActionByType, opts);
|
||||
|
||||
return JsonSerializer.SerializeToElement(new { car, site, track, carActionByType }, opts);
|
||||
}
|
||||
|
||||
private string FilePath(string section) => Path.Combine(_dataDir, $"config-{section}.json");
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record CustomWidget(
|
||||
string Id, string Name, string SchemaJson, string LayoutJson,
|
||||
List<string> BindToScopes);
|
||||
|
||||
public record CustomWidgetConfig(List<CustomWidget> Items)
|
||||
{
|
||||
public static CustomWidgetConfig Default() => new(
|
||||
Items: new List<CustomWidget>
|
||||
{
|
||||
new("widget-call-button", "呼叫按钮",
|
||||
"{\"fields\":[{\"name\":\"siteId\"}]}",
|
||||
"{\"x\":0,\"y\":0,\"w\":2,\"h\":1}",
|
||||
new List<string> { "RCSMonitor" })
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record DeviceDriverBinding(string Id, string DeviceType, string DriverName, string Version);
|
||||
|
||||
public record DeviceInstance(
|
||||
string Id, string Name, string DeviceType, string Protocol, string Address,
|
||||
string DriverId, bool Enabled);
|
||||
|
||||
public record DeviceHealthPolicy(int HeartbeatSec, int OfflineSec);
|
||||
|
||||
public record AlarmRule(string Level, string Condition);
|
||||
public record AlarmPolicy(bool Enabled, List<AlarmRule> Rules);
|
||||
|
||||
public record DeviceManagementConfig(
|
||||
List<DeviceDriverBinding> Drivers,
|
||||
List<DeviceInstance> Devices,
|
||||
DeviceHealthPolicy HealthPolicy,
|
||||
AlarmPolicy AlarmPolicy)
|
||||
{
|
||||
public static DeviceManagementConfig Default() => new(
|
||||
Drivers: new List<DeviceDriverBinding>
|
||||
{
|
||||
new("drv-elev", "电梯", "OpcUaElevatorDriver", "1.2.0"),
|
||||
new("drv-chrg", "充电桩", "ModbusChargerDriver", "1.0.5"),
|
||||
new("drv-cam", "摄像头", "OnvifCameraDriver", "2.1.0")
|
||||
},
|
||||
Devices: new List<DeviceInstance>
|
||||
{
|
||||
new("dev-elev-1", "#1 电梯", "电梯", "opc-ua", "opc.tcp://10.0.2.20:4840", "drv-elev", true),
|
||||
new("dev-chrg-1", "充电桩-A1", "充电桩", "modbus-tcp", "10.0.2.30:502", "drv-chrg", true)
|
||||
},
|
||||
HealthPolicy: new DeviceHealthPolicy(5, 30),
|
||||
AlarmPolicy: new AlarmPolicy(true, new List<AlarmRule>
|
||||
{
|
||||
new("warn", "offline>30s"),
|
||||
new("error", "driverException")
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record WidgetGrantDto(string WidgetId, string Visibility);
|
||||
|
||||
public record AuthRole(
|
||||
string Id, string Name, string Scope,
|
||||
List<string> Permissions,
|
||||
List<WidgetGrantDto> WidgetGrants);
|
||||
|
||||
public record AuthUser(string Id, string Username, List<string> Roles, bool Enabled);
|
||||
|
||||
public record AuthRoleConfig(List<AuthRole> Roles, List<AuthUser> Users)
|
||||
{
|
||||
public static AuthRoleConfig Default() => new(
|
||||
Roles: new List<AuthRole>
|
||||
{
|
||||
new("role-admin", "管理员", "Platform",
|
||||
new List<string> { "*" },
|
||||
new List<WidgetGrantDto>()),
|
||||
new("role-ops", "运营", "RCSMonitor",
|
||||
new List<string>
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome",
|
||||
"ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
},
|
||||
new List<WidgetGrantDto>
|
||||
{
|
||||
new("MapEditor", "readonly"),
|
||||
new("CadToolbar", "hidden")
|
||||
})
|
||||
},
|
||||
Users: new List<AuthUser>
|
||||
{
|
||||
new("u-admin", "admin", new List<string> { "role-admin" }, true),
|
||||
new("u-ops", "ops", new List<string> { "role-ops" }, true)
|
||||
});
|
||||
}
|
||||
|
||||
public record EffectivePermissions(
|
||||
string UserId,
|
||||
int Version,
|
||||
List<string> AllowedOps,
|
||||
List<WidgetGrantDto> VisibleWidgets);
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record EndpointDescriptor(string Id, string Name, string Url, bool Enabled);
|
||||
|
||||
public record ExternalIntegrations(
|
||||
List<EndpointDescriptor> Mes,
|
||||
List<EndpointDescriptor> Wms,
|
||||
List<EndpointDescriptor> Rcs)
|
||||
{
|
||||
public static ExternalIntegrations Default() => new(
|
||||
Mes: new List<EndpointDescriptor> { new("mes-1", "MES 主线", "http://mes.lan/api", true) },
|
||||
Wms: new List<EndpointDescriptor> { new("wms-1", "WMS 仓储", "http://wms.lan/api", true) },
|
||||
Rcs: new List<EndpointDescriptor>());
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record FleetGroup(string Id, string Name, string Floor, string Region, List<string> CarIds);
|
||||
public record OtaPolicy(bool Enabled, int BatchSize, bool RollbackOnFail);
|
||||
public record BatchOpsPolicy(bool ConfirmationRequired, int MaxBatch);
|
||||
public record NetworkDiagPolicy(int RttThresholdMs, double PacketLossThreshold);
|
||||
|
||||
public record FleetLifecycleConfig(
|
||||
List<FleetGroup> Groups,
|
||||
OtaPolicy Ota,
|
||||
BatchOpsPolicy BatchOps,
|
||||
NetworkDiagPolicy NetworkDiag)
|
||||
{
|
||||
public static FleetLifecycleConfig Default() => new(
|
||||
Groups: new List<FleetGroup>
|
||||
{
|
||||
new("G-A", "A 区车队", "F1", "A", new List<string> { "C01", "C02", "C03" }),
|
||||
new("G-B", "B 区车队", "F1", "B", new List<string> { "C04", "C05" })
|
||||
},
|
||||
Ota: new OtaPolicy(true, 2, true),
|
||||
BatchOps: new BatchOpsPolicy(true, 10),
|
||||
NetworkDiag: new NetworkDiagPolicy(80, 0.02));
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record Location(string Id, string Code, string Name, string SiteId, int Capacity, int Occupied);
|
||||
public record InventoryRule(string Id, string ItemType, int MinQty, int MaxQty);
|
||||
|
||||
public record LocationManagement(
|
||||
List<Location> Locations,
|
||||
List<InventoryRule> InventoryRules)
|
||||
{
|
||||
public static LocationManagement Default() => new(
|
||||
Locations: new List<Location>
|
||||
{
|
||||
new("L01", "A-01", "A 区货架 1", "S001", 20, 12),
|
||||
new("L02", "A-02", "A 区货架 2", "S002", 20, 7),
|
||||
new("L03", "B-01", "B 区缓存", "S003", 30, 25)
|
||||
},
|
||||
InventoryRules: new List<InventoryRule>
|
||||
{
|
||||
new("IR1", "PalletA", 5, 30)
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record PlaybackPolicy(int RetentionDays, int SamplingHz);
|
||||
public record LogRetention(int HotDays, int ColdDays);
|
||||
public record VersionPolicy(int KeepReleases);
|
||||
public record MonitorPanelPolicy(string[] PropertyKeys, string[] StatusKeys, string[] ActionKeys);
|
||||
public record MonitorOpsPolicy(
|
||||
MonitorPanelPolicy Car,
|
||||
MonitorPanelPolicy Site,
|
||||
MonitorPanelPolicy Track,
|
||||
Dictionary<string, string[]> CarActionByType);
|
||||
|
||||
public record OpsConfig(
|
||||
PlaybackPolicy Playback,
|
||||
LogRetention LogRetention,
|
||||
VersionPolicy Version,
|
||||
MonitorOpsPolicy Monitor)
|
||||
{
|
||||
public static OpsConfig Default() => new(
|
||||
Playback: new PlaybackPolicy(30, 5),
|
||||
LogRetention: new LogRetention(7, 180),
|
||||
Version: new VersionPolicy(5),
|
||||
Monitor: new MonitorOpsPolicy(
|
||||
Car: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
Site: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
Track: new MonitorPanelPolicy(Array.Empty<string>(), Array.Empty<string>(), Array.Empty<string>()),
|
||||
CarActionByType: new Dictionary<string, string[]>()));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record AvoidanceRule(string Id, string ZoneId, string Rule);
|
||||
public record ZoneSpeedLimit(string ZoneId, double MaxSpeedMps);
|
||||
|
||||
public record RoutingPolicy(
|
||||
string Algorithm,
|
||||
Dictionary<string, double> Weights,
|
||||
List<AvoidanceRule> Avoidance,
|
||||
List<ZoneSpeedLimit> ZoneSpeedLimits)
|
||||
{
|
||||
public static RoutingPolicy Default() => new(
|
||||
Algorithm: "astar",
|
||||
Weights: new Dictionary<string, double>
|
||||
{
|
||||
["distance"] = 1.0,
|
||||
["congestion"] = 0.5,
|
||||
["turnPenalty"] = 0.2
|
||||
},
|
||||
Avoidance: new List<AvoidanceRule> { new("AV1", "Z-NORTH", "no-entry-while-loading") },
|
||||
ZoneSpeedLimits: new List<ZoneSpeedLimit> { new("Z-NARROW", 0.5) });
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record ScenarioTemplate(string Id, string Name, string Category, string Version, string BaselineJson);
|
||||
public record TemplateDslPolicy(bool Enabled, string SchemaVersion);
|
||||
public record LowCodePolicy(bool Enabled, string Editor);
|
||||
public record TemplateVersionPolicy(int KeepVersions, bool AllowRollback);
|
||||
|
||||
public record ScenarioTemplateConfig(
|
||||
List<ScenarioTemplate> Templates,
|
||||
TemplateDslPolicy DslPolicy,
|
||||
LowCodePolicy LowCode,
|
||||
TemplateVersionPolicy VersionPolicy)
|
||||
{
|
||||
public static ScenarioTemplateConfig Default() => new(
|
||||
Templates: new List<ScenarioTemplate>
|
||||
{
|
||||
new("tpl-sps", "SPS 物料配送", "SPS", "1.0.0", "{}"),
|
||||
new("tpl-pack", "电池 Pack 自动化产线", "BatteryPack", "1.0.0", "{}"),
|
||||
new("tpl-loop", "环线运行", "Loop", "1.0.0", "{}"),
|
||||
new("tpl-p2p", "点对点柔性搬运", "P2P", "1.0.0", "{}")
|
||||
},
|
||||
DslPolicy: new TemplateDslPolicy(true, "1"),
|
||||
LowCode: new LowCodePolicy(false, "json"),
|
||||
VersionPolicy: new TemplateVersionPolicy(10, true));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record LogPolicy(string Level, int RollDays, int MaxSizeMB);
|
||||
public record SecurityPolicy(int JwtExpireMin, bool EnableSwagger, List<string> CorsWhitelist);
|
||||
|
||||
public record SystemConfig(int DispatchLoopHz, LogPolicy Log, SecurityPolicy Security)
|
||||
{
|
||||
public static SystemConfig Default() => new(
|
||||
DispatchLoopHz: 50,
|
||||
Log: new LogPolicy("info", 7, 256),
|
||||
Security: new SecurityPolicy(1440, false, new List<string> { "http://localhost:5173" }));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record TaskAllocationPolicy(
|
||||
string Mode,
|
||||
bool LoadBalance,
|
||||
int MaxQueuePerCar)
|
||||
{
|
||||
public static TaskAllocationPolicy Default() => new(
|
||||
Mode: "leastLoad",
|
||||
LoadBalance: true,
|
||||
MaxQueuePerCar: 3);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record IntersectionPolicy(string Id, List<string> SiteIds, string Mode);
|
||||
public record ZoneMutex(string Id, List<string> ZoneIds);
|
||||
public record DynamicYield(string Id, string From, string To, string Condition);
|
||||
|
||||
public record TrafficRule(
|
||||
List<IntersectionPolicy> Intersections,
|
||||
List<ZoneMutex> Mutex,
|
||||
List<DynamicYield> Yields)
|
||||
{
|
||||
public static TrafficRule Default() => new(
|
||||
Intersections: new List<IntersectionPolicy> { new("IX1", new List<string> { "S006", "S007" }, "mutex") },
|
||||
Mutex: new List<ZoneMutex> { new("MZ1", new List<string> { "Z-CROSS" }) },
|
||||
Yields: new List<DynamicYield> { new("YD1", "A 区", "B 区", "priority<peer") });
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace MiGu.Server.Configs;
|
||||
|
||||
public record FaultReportPolicy(bool Enabled, List<string> EmailTo);
|
||||
public record AutoRepairPolicy(bool Enabled, int CooldownSec);
|
||||
|
||||
public record VehicleMaintenancePolicy(
|
||||
double LowBatteryThreshold,
|
||||
double CriticalBatteryThreshold,
|
||||
FaultReportPolicy FaultReport,
|
||||
AutoRepairPolicy AutoRepair)
|
||||
{
|
||||
public static VehicleMaintenancePolicy Default() => new(
|
||||
LowBatteryThreshold: 0.3,
|
||||
CriticalBatteryThreshold: 0.15,
|
||||
FaultReport: new FaultReportPolicy(true, new List<string> { "ops@example.com" }),
|
||||
AutoRepair: new AutoRepairPolicy(false, 600));
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 登录请求体。
|
||||
/// 会话 N+1(启动反转):新增 <see cref="LaunchMode"/>。前端登录页让用户选 "WebOnly" / "DesktopAndWeb";
|
||||
/// MiGu.Server 据此拉起 SimpleLite 子进程并透传 <c>--display-mode=web|web+local</c>。
|
||||
/// 历史调用方不传该字段时默认 "DesktopAndWeb"(与之前 web+local 默认行为一致,向后兼容)。
|
||||
/// </summary>
|
||||
public record LoginRequest(string Username, string Password, string Scope, string? LaunchMode = null);
|
||||
|
||||
/// <summary>
|
||||
/// 登录响应。
|
||||
/// 会话 N+1 增量字段:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>RunMode</c>:根据 SimpleLite 真实拉起结果回填(WebEnabled / WebOnly / Detached)。Detached 表示后端未能拉起 SimpleLite,前端可降级展示。</item>
|
||||
/// <item><c>LaunchStatus</c>:<see cref="SimpleLiteLauncher.LaunchResult.Status"/> 枚举字符串,前端用于精细化提示。</item>
|
||||
/// <item><c>LaunchWarning</c>:可空告警文本;非空时前端应该弹消息条告知用户。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public record LoginResponse(
|
||||
string Token,
|
||||
AuthUserDto User,
|
||||
string Scope,
|
||||
string RunMode,
|
||||
EffectivePermissions EffectivePermissions,
|
||||
string? LaunchStatus = null,
|
||||
string? LaunchWarning = null);
|
||||
|
||||
public record AuthUserDto(string Id, string Username, string DisplayName, List<string> Roles);
|
||||
|
||||
/// <summary>
|
||||
/// 当前会话身份的轻量摘要。
|
||||
/// 用途:前端路由守卫在受保护路由首次进入前调用 <c>GET /api/auth/me</c>,
|
||||
/// 用 [Authorize] 实校验本地 token 是否仍被服务端接受(MiGu.Server 重启后
|
||||
/// JWT secret 可能已重生 → 老 token 会被拒),同时刷新 user / scope / runMode / perm。
|
||||
/// 与 <see cref="LoginResponse"/> 的区别:不返回 Token(client 已有,重发反而易触发竞态);
|
||||
/// 不返回 LaunchStatus/Warning(那是登录时一次性的 SimpleLite 拉起结果)。
|
||||
/// </summary>
|
||||
public record MeResponse(
|
||||
AuthUserDto User,
|
||||
string Scope,
|
||||
string RunMode,
|
||||
EffectivePermissions EffectivePermissions);
|
||||
|
||||
private static readonly string[] PlatformOps = { "*" };
|
||||
|
||||
private static readonly string[] RcsOps =
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
};
|
||||
|
||||
private const string CookieName = "simple.auth.token";
|
||||
|
||||
private readonly UserStore _users;
|
||||
private readonly JwtIssuer _jwt;
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
private readonly ILogger<AuthController> _log;
|
||||
|
||||
public AuthController(UserStore users, JwtIssuer jwt, SimpleLiteLauncher launcher, ILogger<AuthController> log)
|
||||
{
|
||||
_users = users;
|
||||
_jwt = jwt;
|
||||
_launcher = launcher;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<LoginResponse>> Login([FromBody] LoginRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.Username))
|
||||
return BadRequest(new { message = "用户名不能为空" });
|
||||
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
||||
return BadRequest(new { message = "无效 scope" });
|
||||
|
||||
// AR-3: 真密码校验 —— 替代会话21 点名的「完全不验密码」漏洞。
|
||||
// UserStore.Verify 对不存在用户和密码错都返回 null,防用户名枚举。
|
||||
var rec = _users.Verify(req.Username, req.Password);
|
||||
if (rec == null)
|
||||
return Unauthorized(new { message = "用户名或密码错误" });
|
||||
|
||||
// scope 与角色匹配检查:admin 默认 Platform;ops 默认 RCSMonitor。
|
||||
// 如果 ops 想登录 Platform scope,目前直接拒绝;后续可以加 role-platform-impersonate 之类。
|
||||
if (!CanUseScope(rec, req.Scope))
|
||||
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" });
|
||||
|
||||
// 会话 N+1(启动反转):按 LaunchMode 拉起 SimpleLite 子进程。
|
||||
// - 历史前端不带该字段 → 默认 DesktopAndWeb(保持向后兼容的 web+local 行为)。
|
||||
// - WebOnly → SimpleLite 启动时只起 WebTerminal,不会弹本地桌面窗口。
|
||||
// - DesktopAndWeb → SimpleLite 同时起 LocalTerminal + WebTerminal。
|
||||
// - SimpleLiteLauncher 内部幂等:第二次/第 N 次登录不会重复拉起;子进程退出后下一次登录可重启。
|
||||
// PERF-A8 修复:MaybeStart 内部包含同步 WaitForProjectionReady(最多 ReadinessTimeoutMs,默认 8s)。
|
||||
// 走 Task.Run 把它扔到线程池,让登录请求自身的请求处理线程释放回 ASP.NET,避免高并发下挤兑。
|
||||
var launchMode = NormalizeLaunchMode(req.LaunchMode);
|
||||
SimpleLiteLauncher.LaunchResult? launchResult = null;
|
||||
try
|
||||
{
|
||||
launchResult = await Task.Run(() => _launcher.MaybeStart(launchMode));
|
||||
_log.LogInformation("SimpleLite launch result for user={User} launchMode={Mode}: Started={Started} Status={Status} Detail={Detail}",
|
||||
rec.Username, launchMode, launchResult.Value.Started, launchResult.Value.Status, launchResult.Value.Detail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 启动 SimpleLite 失败不应阻断登录:用户至少能进 Platform 看状态页面排查。
|
||||
_log.LogError(ex, "SimpleLite launch threw for user={User} launchMode={Mode}", rec.Username, launchMode);
|
||||
}
|
||||
|
||||
// A7 修复:runMode 跟 result.Started 走 ——
|
||||
// - launcher 没起来 / 抛异常 → "Detached",前端显示降级状态而不是"假装 SimpleLite 在跑"。
|
||||
// - 跑起来了 → 优先按 launcher 回传的真实 DisplayMode 映射,避免 AlreadyRunning 时被本次请求的 launchMode 误导。
|
||||
// - ReusingExisting 时既有 SimpleLite 实际模式未知,保守归 WebEnabled(旧实例最可能带本地窗口)。
|
||||
var runMode = ResolveRunMode(launchResult, launchMode);
|
||||
|
||||
var (allowedOps, widgets) = BuildPermissions(req.Scope);
|
||||
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||
var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList();
|
||||
|
||||
var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps);
|
||||
SetAuthCookie(token);
|
||||
|
||||
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||
return Ok(new LoginResponse(token, user, req.Scope, runMode, perm,
|
||||
LaunchStatus: launchResult?.Status,
|
||||
LaunchWarning: launchResult?.Warning));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A7 修复:根据 Launcher 真实结果决定 RunMode。
|
||||
/// 之前的逻辑直接按 launchMode 映射,导致 SimpleLite 没起也假装"WebEnabled",前端 RunMode 角标骗人。
|
||||
/// </summary>
|
||||
private static string ResolveRunMode(SimpleLiteLauncher.LaunchResult? result, string launchMode)
|
||||
{
|
||||
if (result is not { Started: true })
|
||||
return "Detached";
|
||||
// ReusingExisting 时 DisplayMode = "external",无法确认本地窗口是否存在,保守按 WebEnabled。
|
||||
if (result.Value.Status == "ReusingExisting")
|
||||
return "WebEnabled";
|
||||
if (!string.IsNullOrEmpty(result.Value.DisplayMode))
|
||||
{
|
||||
if (result.Value.DisplayMode.Equals("web", StringComparison.OrdinalIgnoreCase))
|
||||
return "WebOnly";
|
||||
if (result.Value.DisplayMode.Contains("local", StringComparison.OrdinalIgnoreCase))
|
||||
return "WebEnabled";
|
||||
}
|
||||
return launchMode == "WebOnly" ? "WebOnly" : "WebEnabled";
|
||||
}
|
||||
|
||||
[HttpPost("logout")]
|
||||
[AllowAnonymous]
|
||||
public IActionResult Logout()
|
||||
{
|
||||
Response.Cookies.Delete(CookieName);
|
||||
return Ok(new { ok = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用本地持有的 token / Cookie 重新拉一次当前身份。失败(token 过期、签名变更、用户被删等)由
|
||||
/// [Authorize] 自动回 401,前端 axios 拦截器在 http.ts:57 会清 localStorage + 跳 /login。
|
||||
/// 用途:解决「MiGu.Server 随机 secret 重启 → 老 token 失效 → 前端 isAuthed 仍为 true 误放行」的窗口。
|
||||
/// </summary>
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public ActionResult<MeResponse> Me()
|
||||
{
|
||||
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var scope = User.FindFirstValue("scope");
|
||||
|
||||
if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(scope))
|
||||
return Unauthorized(new { message = "身份无效" });
|
||||
if (scope is not ("Platform" or "RCSMonitor"))
|
||||
return Unauthorized(new { message = "无效 scope" });
|
||||
|
||||
var rec = _users.Find(username);
|
||||
if (rec == null)
|
||||
return Unauthorized(new { message = "账号已失效" });
|
||||
|
||||
// 账号当前是否还允许这个 scope —— 比如运维把 admin 的角色去掉了,也要在这里及时回 403。
|
||||
if (!CanUseScope(rec, scope))
|
||||
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {scope} 的权限" });
|
||||
|
||||
var (allowedOps, widgets) = BuildPermissions(scope);
|
||||
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||
var roles = rec.Roles.Concat(new[] { scope == "Platform" ? "role-platform" : "role-rcs-monitor" })
|
||||
.Distinct().ToList();
|
||||
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||
|
||||
// RunMode 推断:与 SwitchScope 保持一致 —— 复用 Launcher 记录的 LastLaunchMode,
|
||||
// 不重新拉起 SimpleLite。
|
||||
var last = _launcher.LastLaunchMode;
|
||||
string runMode;
|
||||
if (string.IsNullOrEmpty(last))
|
||||
runMode = "Detached";
|
||||
else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode)
|
||||
runMode = "WebEnabled";
|
||||
else
|
||||
runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||
|
||||
return Ok(new MeResponse(user, scope, runMode, perm));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 用同一身份切换 scope 并重发 token + perms。
|
||||
/// AR-6: 替代前端 stores/auth.ts 里硬编码改 allowedOps 的客户端伪权限。
|
||||
/// 当前服务端只放行账号的 DefaultScope,以及 admin 类账号显式允许的额外 scope。
|
||||
/// </summary>
|
||||
[HttpPost("switch-scope")]
|
||||
[Authorize]
|
||||
public ActionResult<LoginResponse> SwitchScope([FromBody] SwitchScopeRequest req)
|
||||
{
|
||||
if (req.Scope is not ("Platform" or "RCSMonitor"))
|
||||
return BadRequest(new { message = "无效 scope" });
|
||||
|
||||
var username = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrEmpty(username))
|
||||
return Unauthorized(new { message = "身份无效" });
|
||||
|
||||
var rec = _users.Find(username);
|
||||
if (rec == null)
|
||||
return Unauthorized(new { message = "账号已失效" });
|
||||
|
||||
if (!CanUseScope(rec, req.Scope))
|
||||
return StatusCode(403, new { message = $"账号 {rec.Username} 没有访问 {req.Scope} 的权限" });
|
||||
|
||||
var (allowedOps, widgets) = BuildPermissions(req.Scope);
|
||||
var perm = new EffectivePermissions(rec.Id, 1, allowedOps.ToList(), widgets.ToList());
|
||||
var roles = rec.Roles.Concat(new[] { req.Scope == "Platform" ? "role-platform" : "role-rcs-monitor" }).Distinct().ToList();
|
||||
var token = _jwt.Issue(rec.Id, rec.Username, req.Scope, roles, allowedOps);
|
||||
SetAuthCookie(token);
|
||||
|
||||
// 复用首次登录确定的 LaunchMode:SwitchScope 不重新选启动模式(也不应该重启 SimpleLite)。
|
||||
// 三种情况:
|
||||
// 1) Launcher 已记录 "web" / "web+local" → 按 displayMode 反推 runMode。
|
||||
// 2) Launcher 记录 ExternalReuseLaunchMode(既有 SimpleLite 复用)→ 模式未知,保守 WebEnabled。
|
||||
// 3) Launcher 没拉起 / LastLaunchMode = null → Detached(前端降级展示)。
|
||||
var last = _launcher.LastLaunchMode;
|
||||
string runMode;
|
||||
if (string.IsNullOrEmpty(last))
|
||||
runMode = "Detached";
|
||||
else if (last == SimpleLiteLauncher.ExternalReuseLaunchMode)
|
||||
runMode = "WebEnabled";
|
||||
else
|
||||
runMode = last.Contains("local", StringComparison.OrdinalIgnoreCase) ? "WebEnabled" : "WebOnly";
|
||||
|
||||
var user = new AuthUserDto(rec.Id, rec.Username, rec.DisplayName, roles);
|
||||
return Ok(new LoginResponse(token, user, req.Scope, runMode, perm));
|
||||
}
|
||||
|
||||
public record SwitchScopeRequest(string Scope);
|
||||
|
||||
/// <summary>
|
||||
/// 把前端传入的 LaunchMode 归一为枚举字符串("WebOnly" / "DesktopAndWeb")。
|
||||
/// null / 空 / 未知值统一退到 "DesktopAndWeb",避免历史前端不带该字段时打破默认行为。
|
||||
/// </summary>
|
||||
private static string NormalizeLaunchMode(string? raw)
|
||||
{
|
||||
return raw?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"webonly" or "web-only" or "web" => "WebOnly",
|
||||
_ => "DesktopAndWeb",
|
||||
};
|
||||
}
|
||||
|
||||
private static bool CanUseScope(UserStore.UserRecord rec, string scope)
|
||||
{
|
||||
if (scope == rec.DefaultScope) return true;
|
||||
// 管理员可以下沉到 RCSMonitor 体验运营视角;ops 不能上探 Platform。
|
||||
if (scope == "RCSMonitor" && rec.Roles.Contains("role-admin")) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static (string[] ops, WidgetGrantDto[] widgets) BuildPermissions(string scope)
|
||||
{
|
||||
var ops = scope == "Platform" ? PlatformOps : RcsOps;
|
||||
var widgets = scope == "Platform"
|
||||
? new[]
|
||||
{
|
||||
new WidgetGrantDto("MapEditor", "interactive"),
|
||||
new WidgetGrantDto("CadToolbar", "interactive"),
|
||||
new WidgetGrantDto("CarPanel", "interactive"),
|
||||
new WidgetGrantDto("MissionEditor", "interactive"),
|
||||
new WidgetGrantDto("OpsActionPanel", "interactive"),
|
||||
new WidgetGrantDto("ConfigCenter", "interactive")
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
new WidgetGrantDto("MapEditor", "readonly"),
|
||||
new WidgetGrantDto("CadToolbar", "hidden"),
|
||||
new WidgetGrantDto("CarPanel", "readonly"),
|
||||
new WidgetGrantDto("MissionEditor", "readonly"),
|
||||
new WidgetGrantDto("OpsActionPanel", "interactive"),
|
||||
new WidgetGrantDto("ConfigCenter", "hidden")
|
||||
};
|
||||
return (ops, widgets);
|
||||
}
|
||||
|
||||
private void SetAuthCookie(string token)
|
||||
{
|
||||
// AR-5: 同时下发 httpOnly Cookie(XSS 防护)+ Bearer 兼容(旧前端过渡)。
|
||||
// SameSite=Lax 足够:管理端 / 监控端均为同源(同一 MiGu.Server 进程),
|
||||
// 第三方请求不应也无法附带 Cookie;Strict 会让一些 SPA 路由首次刷新认证丢失。
|
||||
Response.Cookies.Append(CookieName, token, new CookieOptions
|
||||
{
|
||||
HttpOnly = true,
|
||||
Secure = Request.IsHttps,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/",
|
||||
Expires = DateTimeOffset.UtcNow.AddHours(24)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Configs;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
// AR-4: 全 class 加 [Authorize] —— 替代会话21 点名的「ConfigController 无鉴权 PUT 任意 section」漏洞。
|
||||
// GET (List/Get) 只要登录就放;PUT 强制 PlatformScope,避免运营人员误改业务配置。
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/config")]
|
||||
public class ConfigController : ControllerBase
|
||||
{
|
||||
private readonly ConfigStore _store;
|
||||
|
||||
public ConfigController(ConfigStore store)
|
||||
{
|
||||
_store = store;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult List()
|
||||
{
|
||||
var envs = _store.List().Select(e => new
|
||||
{
|
||||
section = e.Section,
|
||||
version = e.Version,
|
||||
updatedAt = e.UpdatedAt
|
||||
});
|
||||
return Ok(envs);
|
||||
}
|
||||
|
||||
[HttpGet("{section}")]
|
||||
public IActionResult Get(string section)
|
||||
{
|
||||
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||
return NotFound(new { message = $"未知 section: {section}" });
|
||||
|
||||
var env = _store.Get(section);
|
||||
return Ok(new
|
||||
{
|
||||
section = env.Section,
|
||||
version = env.Version,
|
||||
updatedAt = env.UpdatedAt,
|
||||
payload = env.Payload
|
||||
});
|
||||
}
|
||||
|
||||
// 配置中心页面已有 PermissionGuard;此处仅要求登录即可保存,避免 RCSMonitor scope
|
||||
// 账号在特殊场景下无法写入 ops.monitor(地图监控动作)备份字段。
|
||||
[HttpPut("{section}")]
|
||||
[Authorize]
|
||||
public IActionResult Put(string section, [FromBody] JsonElement payload)
|
||||
{
|
||||
if (!ConfigStore.AllSections.Contains(section, StringComparer.OrdinalIgnoreCase))
|
||||
return NotFound(new { message = $"未知 section: {section}" });
|
||||
|
||||
var env = _store.Put(section, payload);
|
||||
return Ok(new
|
||||
{
|
||||
section = env.Section,
|
||||
version = env.Version,
|
||||
updatedAt = env.UpdatedAt,
|
||||
payload = env.Payload
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Launcher;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/health")]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
|
||||
private readonly SimpleLiteLauncher _launcher;
|
||||
|
||||
public HealthController(SimpleLiteLauncher launcher) => _launcher = launcher;
|
||||
|
||||
[HttpGet]
|
||||
public IActionResult Get()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
status = "ok",
|
||||
mode = "WebEnabled",
|
||||
startTime = StartTime,
|
||||
uptimeSec = (long)(DateTimeOffset.UtcNow - StartTime).TotalSeconds,
|
||||
ports = new
|
||||
{
|
||||
webApi = 7001,
|
||||
webSocket = 7002,
|
||||
platform = 8080,
|
||||
vrender = 8223,
|
||||
vehicle = 8222
|
||||
},
|
||||
architecture = "v1.5"
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// SimpleLite 拉起配置诊断:查看当前 ExecutablePath、解析结果、端口是否已有服务。
|
||||
/// 配置位置:<c>MiGu.Server/appsettings.json</c> → <c>SimpleLite</c> 节点。
|
||||
/// </summary>
|
||||
[HttpGet("simplelite")]
|
||||
public IActionResult GetSimpleLiteDiagnostics() => Ok(_launcher.GetDiagnostics());
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 运维白名单网关(占位)。真实落地时按架构 §5.1 + §10.5:
|
||||
/// - 校验 scope=RCSMonitor 是否允许该 op;
|
||||
/// - YARP 转发到 SimpleLite /api/ops/*;
|
||||
/// - 写 OpsAuditLog(simple_main.db) + 写 OpsLogs(platform.db)。
|
||||
///
|
||||
/// AR-4: 全 class 加 [Authorize] —— 至少要求登录,再按 op 白名单 + JWT ops claim 双校验。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/sl/ops")]
|
||||
public class OpsController : ControllerBase
|
||||
{
|
||||
public record ExecuteRequest(string OpCode, string TargetId, string? Reason, string? IdempotencyKey);
|
||||
public record ExecuteResponse(bool Ok, string AuditId, string? Message);
|
||||
public record AuditEntry(string Id, DateTimeOffset Ts, string User, string Scope, string OpCode, string Target, string Result, string? Message);
|
||||
|
||||
private static readonly ConcurrentQueue<AuditEntry> Audits = new();
|
||||
private static long _seq = 0;
|
||||
|
||||
private static readonly HashSet<string> Whitelist = new(StringComparer.Ordinal)
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||
"ops.car.manualCharge", "ops.task.pause", "ops.task.cancel", "ops.task.reassign",
|
||||
"ops.task.boostPriority", "monitor.note.write"
|
||||
};
|
||||
|
||||
[HttpPost("execute")]
|
||||
public ActionResult<ExecuteResponse> Execute([FromBody] ExecuteRequest req)
|
||||
{
|
||||
if (!Whitelist.Contains(req.OpCode))
|
||||
return BadRequest(new { message = $"非白名单 op:{req.OpCode}" });
|
||||
|
||||
// AR-4: JWT ops claim 二次校验 —— JWT 颁发时已写入用户被授权的 ops 列表(空格分隔),
|
||||
// 这里要求 (op 在白名单) AND (op 在用户 ops claim);admin 的 "*" 会被特判通过。
|
||||
var opsClaim = User.FindFirst("ops")?.Value ?? "";
|
||||
var userOps = opsClaim.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (!userOps.Contains("*") && !userOps.Contains(req.OpCode))
|
||||
return StatusCode(403, new { message = $"当前账号无权执行 {req.OpCode}" });
|
||||
|
||||
var user = User.Identity?.Name ?? User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anonymous";
|
||||
var scope = User.FindFirst("scope")?.Value ?? "unknown";
|
||||
|
||||
var id = $"A{Interlocked.Increment(ref _seq):D6}";
|
||||
var entry = new AuditEntry(id, DateTimeOffset.UtcNow, user, scope,
|
||||
req.OpCode, req.TargetId, "ok", req.Reason);
|
||||
Audits.Enqueue(entry);
|
||||
while (Audits.Count > 200 && Audits.TryDequeue(out _)) { }
|
||||
return Ok(new ExecuteResponse(true, id, null));
|
||||
}
|
||||
|
||||
[HttpGet("audits")]
|
||||
public IActionResult Audits200()
|
||||
{
|
||||
return Ok(Audits.Reverse());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// 投影 API 占位:真实落地时由 YARP 反代到 SimpleLite WebAPI 的 /api/projection/* 路径。
|
||||
/// 本地 Mock 数据仅用于无 SimpleLite 运行时的开发联调。
|
||||
///
|
||||
/// AR-4: 全 class 加 [Authorize] —— 任何登录用户都能读 mock 投影数据;未登录直接 401。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/projection")]
|
||||
public class ProjectionController : ControllerBase
|
||||
{
|
||||
[HttpGet("sites")]
|
||||
public IActionResult Sites() => Ok(new[]
|
||||
{
|
||||
new { id = "S001", name = "A 区-入库点", x = 1000, y = 2000 },
|
||||
new { id = "S002", name = "A 区-出库点", x = 3000, y = 2000 },
|
||||
new { id = "S003", name = "B 区-缓存区", x = 5000, y = 2000 }
|
||||
});
|
||||
|
||||
[HttpGet("tracks")]
|
||||
public IActionResult Tracks() => Ok(new[]
|
||||
{
|
||||
new { id = "T001", kind = "line", fromSiteId = "S001", toSiteId = "S002" },
|
||||
new { id = "T002", kind = "line", fromSiteId = "S002", toSiteId = "S003" }
|
||||
});
|
||||
|
||||
[HttpGet("cars")]
|
||||
public IActionResult Cars() => Ok(new[]
|
||||
{
|
||||
new { id = "C01", name = "AGV-001", state = "running", batterySoc = 0.86 },
|
||||
new { id = "C02", name = "AGV-002", state = "idle", batterySoc = 0.42 }
|
||||
});
|
||||
|
||||
[HttpGet("missions")]
|
||||
public IActionResult Missions() => Ok(new[]
|
||||
{
|
||||
new { id = "M01", name = "A 区送料 #1", status = "running", priority = 50 },
|
||||
new { id = "M02", name = "A→B 缓存搬运", status = "queued", priority = 60 }
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net.Sockets;
|
||||
using System.Runtime.InteropServices;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// 平台登录成功后按 LaunchMode 把 SimpleLite.exe 作为子进程拉起。
|
||||
///
|
||||
/// 设计要点(与原 <c>SimpleLite/Platform/PlatformLauncher.cs</c> 镜像):
|
||||
/// - 幂等:第一次登录拉起,后续登录复用同一个子进程(不会重复启)。
|
||||
/// - 子进程退出后下一次登录可以重新拉起(不阻塞用户重试)。
|
||||
/// - 生命周期跟随:Windows 上用 JobObject 把子进程绑定到 MiGu.Server 进程;
|
||||
/// MiGu.Server 被 kill -9 / 任务管理器 → 结束进程树 时,SimpleLite 一并被 OS SIGKILL。
|
||||
/// - 命令行透传:把 LaunchMode 翻成 SimpleLite 的 <c>--display-mode=web</c> / <c>--display-mode=web+local</c>。
|
||||
/// - 启动后阻塞等待 SimpleLite Projection :8222 端口可达(最长 ReadinessTimeoutMs),让前端 /api/sl/* 不再立刻 502。
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteLauncher : IDisposable
|
||||
{
|
||||
private readonly SimpleLiteOptions _opts;
|
||||
private readonly ILogger<SimpleLiteLauncher> _log;
|
||||
private readonly IHostEnvironment _env;
|
||||
private readonly object _sync = new();
|
||||
|
||||
private Process? _proc;
|
||||
private IntPtr _job = IntPtr.Zero;
|
||||
private string? _lastLaunchMode;
|
||||
|
||||
public SimpleLiteLauncher(IOptions<SimpleLiteOptions> opts, ILogger<SimpleLiteLauncher> log, IHostEnvironment env)
|
||||
{
|
||||
_opts = opts.Value;
|
||||
_log = log;
|
||||
_env = env;
|
||||
// 会话 N+2:默认不再在 ProcessExit 时清理子进程 —— SimpleLite 是「独立程序」,MiGu.Server 关掉
|
||||
// 不应该带走 SimpleLite。仅当用户显式 opt-in FollowParent=true 时才挂软关闭兜底。
|
||||
if (_opts.FollowParent)
|
||||
{
|
||||
AppDomain.CurrentDomain.ProcessExit += (_, _) => Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync) return _proc is { HasExited: false };
|
||||
}
|
||||
}
|
||||
|
||||
public string? LastLaunchMode { get { lock (_sync) return _lastLaunchMode; } }
|
||||
|
||||
/// <summary>外部已存在的 SimpleLite(MiGu.Server 重启复用上一轮实例)占位 LaunchMode 值,不参与命令行 displayMode 翻译。</summary>
|
||||
internal const string ExternalReuseLaunchMode = "external";
|
||||
|
||||
/// <summary>
|
||||
/// 按 launchMode 拉起 SimpleLite(已运行则跳过)。
|
||||
/// </summary>
|
||||
/// <param name="launchMode">"WebOnly" 或 "DesktopAndWeb"(大小写不敏感)。</param>
|
||||
/// <returns>本次调用产生的状态摘要,可写入登录响应或日志。</returns>
|
||||
public LaunchResult MaybeStart(string launchMode)
|
||||
{
|
||||
var displayMode = NormalizeDisplayMode(launchMode);
|
||||
|
||||
if (!_opts.Enabled)
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] auto-start disabled (appsettings: SimpleLite.Enabled=false); launchMode={Mode} ignored", launchMode);
|
||||
return new LaunchResult(false, "Disabled", "appsettings:SimpleLite:Enabled=false", DisplayMode: null,
|
||||
Warning: "SimpleLite 自动拉起已被 appsettings:SimpleLite:Enabled=false 关闭;登录已成功但 SimpleLite 未启动,/api/sl/* 反代请求会返回 502。");
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (_proc is { HasExited: false })
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] already running pid={Pid}, displayMode={DisplayMode}; skip duplicate launch", _proc.Id, _lastLaunchMode);
|
||||
var warning = !string.IsNullOrEmpty(_lastLaunchMode) &&
|
||||
!string.Equals(_lastLaunchMode, displayMode, StringComparison.OrdinalIgnoreCase)
|
||||
? $"SimpleLite 已经在运行(pid={_proc.Id}, displayMode={_lastLaunchMode})。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用;如需切换,请手动关闭旧 SimpleLite 后重新登录。"
|
||||
: null;
|
||||
return new LaunchResult(true, "AlreadyRunning",
|
||||
$"pid={_proc.Id}, displayMode={_lastLaunchMode}",
|
||||
DisplayMode: _lastLaunchMode,
|
||||
Warning: warning);
|
||||
}
|
||||
|
||||
// 会话 N+2:MiGu.Server 重启后 _proc 引用丢失,但上一轮拉起的 SimpleLite 可能仍在跑(因为
|
||||
// 默认 FollowParent=false 不带走它)。这里在拉起前先探测 Projection 端口:能连通就视为复用,
|
||||
// 避免「allowMultiple=false 时新 SimpleLite 检测到多开自杀」+「端口冲突」两类常见崩溃。
|
||||
//
|
||||
// 注意:探测仅判断「有 SimpleLite 在 8222 占着」,无法知道它当时选的 LaunchMode。如果用户本次
|
||||
// 想换模式,这条路径下不会生效;日志里会明确告知,由用户决定是否手动关掉旧 SimpleLite 再登录。
|
||||
//
|
||||
// 增强(A3):先做 TCP 探活(快),再做 HTTP JSON 探针验证「真的是 SimpleLite」,避免被某个偶然占
|
||||
// 用 8222 的无关进程误判成复用。Probe 失败时不再当作复用成功,否则前端会拿到假的 WebEnabled。
|
||||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(500)))
|
||||
{
|
||||
var probeOk = ProbeSimpleLiteHttp("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(800));
|
||||
if (!probeOk)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[SimpleLite] projection port :{Port} is occupied, but SimpleLite HTTP probe failed. Skip launch to avoid port conflict.",
|
||||
_opts.ProjectionPort);
|
||||
return new LaunchResult(false, "PortOccupied",
|
||||
$"projection :{_opts.ProjectionPort} tcp reachable but /projection/cars probe failed",
|
||||
DisplayMode: null,
|
||||
Warning: $"端口 :{_opts.ProjectionPort} 已被占用,但未识别为 SimpleLite Projection 服务。" +
|
||||
"请关闭占用该端口的进程,或调整 SimpleLite:ProjectionPort / SimpleLite 配置后重试。");
|
||||
}
|
||||
|
||||
_log.LogInformation(
|
||||
"[SimpleLite] projection :{Port} already reachable (httpProbe=ok); assume an existing SimpleLite is running. " +
|
||||
"Skip launch. If user picked a different LaunchMode this session, please close the existing SimpleLite window and login again.",
|
||||
_opts.ProjectionPort);
|
||||
|
||||
// A2 修复:保留一个占位 LaunchMode 让 LastLaunchMode 不再为 null —— 这样 SwitchScope
|
||||
// 推断 runMode 时不会落到 "web+local" 兜底,前端 RunMode 角标也不会与实际不符。
|
||||
_lastLaunchMode = ExternalReuseLaunchMode;
|
||||
|
||||
return new LaunchResult(true, "ReusingExisting",
|
||||
$"projection :{_opts.ProjectionPort} reachable; requested displayMode={displayMode} not applied to existing instance",
|
||||
DisplayMode: ExternalReuseLaunchMode,
|
||||
Warning: $"检测到 SimpleLite 已经在 :{_opts.ProjectionPort} 上运行(可能是 MiGu.Server 重启前残留)。" +
|
||||
$"本次选择的启动模式『{launchMode}』未被应用到既有实例。如需切换,请手动关闭旧 SimpleLite 窗口后重新登录。");
|
||||
}
|
||||
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
if (resolved == null)
|
||||
{
|
||||
_log.LogWarning(
|
||||
"[SimpleLite] auto-start skipped: SimpleLite.exe not found. Configure appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server. ContentRoot={Root}",
|
||||
_env.ContentRootPath);
|
||||
return new LaunchResult(false, "ExecutableNotFound",
|
||||
"Set appsettings:SimpleLite:ExecutablePath or place SimpleLite next to MiGu.Server.",
|
||||
DisplayMode: null,
|
||||
Warning: "找不到 SimpleLite.exe。请在 appsettings:SimpleLite:ExecutablePath 显式配置,或者把 SimpleLite.exe 放到 MiGu.Server 同目录。");
|
||||
}
|
||||
|
||||
var workdir = string.IsNullOrWhiteSpace(_opts.WorkingDirectory)
|
||||
? Path.GetDirectoryName(resolved) ?? Environment.CurrentDirectory
|
||||
: Path.GetFullPath(_opts.WorkingDirectory);
|
||||
|
||||
var arguments = BuildArguments(displayMode, _opts.Arguments);
|
||||
|
||||
// 会话 N+2:把 SimpleLite 作为独立进程拉起,**带自己的控制台窗口**。
|
||||
// - UseShellExecute=true:交给 OS Shell 启动,生成新的进程组 + 独立 console,
|
||||
// MiGu.Server 关闭不会影响它(不再处于父进程的「controlling process」链上)。
|
||||
// - CreateNoWindow=false + 不重定向 stdout/stderr:SimpleLite 自带 OutputType=Exe 控制台,
|
||||
// 即便选 webonly 模式也会有一个黑底控制台显示日志,便于用户观察 / 关闭。
|
||||
// - 不能与 RedirectStandard* 共用,要让日志独立就只能让用户看 SimpleLite 自己的窗口。
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = resolved,
|
||||
Arguments = arguments,
|
||||
WorkingDirectory = workdir,
|
||||
UseShellExecute = true,
|
||||
CreateNoWindow = false,
|
||||
WindowStyle = ProcessWindowStyle.Normal,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
|
||||
proc.Exited += (_, _) =>
|
||||
{
|
||||
int exit;
|
||||
try { exit = proc.ExitCode; } catch { exit = -1; }
|
||||
_log.LogInformation("[SimpleLite] process exited code={Code}; next login will relaunch if needed", exit);
|
||||
lock (_sync)
|
||||
{
|
||||
if (ReferenceEquals(_proc, proc))
|
||||
{
|
||||
_proc = null;
|
||||
_lastLaunchMode = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (!proc.Start())
|
||||
{
|
||||
_log.LogError("[SimpleLite] Process.Start returned false; exe={Exe} args={Args}", resolved, arguments);
|
||||
return new LaunchResult(false, "ProcessStartFailed", $"exe={resolved}", DisplayMode: null,
|
||||
Warning: $"启动 SimpleLite 进程失败(Process.Start 返回 false);exe={resolved}");
|
||||
}
|
||||
|
||||
_proc = proc;
|
||||
_lastLaunchMode = displayMode;
|
||||
|
||||
// FollowParent 仅在用户显式 opt-in 时启用;UseShellExecute=true 之下 JobObject 仍可绑定 pid,
|
||||
// 但会破坏「独立程序」语义,因此默认 false(见 SimpleLiteOptions.FollowParent 说明)。
|
||||
if (_opts.FollowParent) AttachToJobObject(proc);
|
||||
|
||||
_log.LogInformation("[SimpleLite] launched pid={Pid} displayMode={Mode} exe={Exe} args=\"{Args}\" workdir={Workdir}; standalone={Standalone}",
|
||||
proc.Id, displayMode, resolved, arguments, workdir, !_opts.FollowParent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogError(ex, "[SimpleLite] auto-start failed");
|
||||
return new LaunchResult(false, "Exception", ex.Message, DisplayMode: null,
|
||||
Warning: $"启动 SimpleLite 时抛异常:{ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
var ready = WaitForProjectionReady();
|
||||
return new LaunchResult(true, ready ? "Ready" : "StartedButNotReady",
|
||||
ready ? $"projection :{_opts.ProjectionPort} reachable, displayMode={displayMode}"
|
||||
: $"projection :{_opts.ProjectionPort} did not respond within {_opts.ReadinessTimeoutMs}ms, displayMode={displayMode}",
|
||||
DisplayMode: displayMode,
|
||||
Warning: ready
|
||||
? null
|
||||
: $"SimpleLite 进程已起,但 Projection :{_opts.ProjectionPort} 在 {_opts.ReadinessTimeoutMs}ms 内未响应。前端 /api/sl/* 可能短暂 502;可稍后刷新。");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 手动关闭 SimpleLite 子进程。
|
||||
/// 会话 N+2 起默认不调(FollowParent=false)—— SimpleLite 是独立程序,MiGu.Server 关闭不带走它。
|
||||
/// 仅当用户显式 opt-in FollowParent=true 时由 ProcessExit / ApplicationStopping 钩子调用。
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Process? proc;
|
||||
IntPtr job;
|
||||
lock (_sync)
|
||||
{
|
||||
proc = _proc;
|
||||
job = _job;
|
||||
_proc = null;
|
||||
_job = IntPtr.Zero;
|
||||
}
|
||||
|
||||
if (!_opts.FollowParent)
|
||||
{
|
||||
// 独立模式:不真的杀子进程,只释放本地引用让 GC 收 Process handle。
|
||||
// 这样 MiGu.Server 重启时下一次登录可以重新 MaybeStart 拉一个新的 SimpleLite,
|
||||
// 而老 SimpleLite 仍然在自己的窗口里跑。
|
||||
if (proc is { HasExited: false })
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] standalone mode: skip kill on Dispose; pid={Pid} keeps running", proc.Id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (proc is { HasExited: false })
|
||||
{
|
||||
try { proc.Kill(entireProcessTree: true); }
|
||||
catch (Exception ex) { _log.LogWarning("[SimpleLite] kill failed: {Msg}", ex.Message); }
|
||||
try { proc.WaitForExit(2000); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (job != IntPtr.Zero)
|
||||
{
|
||||
try { CloseHandle(job); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeDisplayMode(string launchMode)
|
||||
{
|
||||
// LaunchMode 是「业务语言」(WebOnly / DesktopAndWeb);displayMode 是 SimpleLite「内部语言」(web / web+local)。
|
||||
// 这里做一次显式翻译,前端 / 后端日志均用业务语言,命令行用内部语言。
|
||||
return launchMode?.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"webonly" or "web" or "web-only" => "web",
|
||||
// 任何未知值都按"完整本地+web"兜底,保证最小惊讶(既能桌面用,也能浏览器用)。
|
||||
_ => "web+local",
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildArguments(string displayMode, string extra)
|
||||
{
|
||||
var args = $"--display-mode={displayMode}";
|
||||
if (!string.IsNullOrWhiteSpace(extra)) args += " " + extra.Trim();
|
||||
return args;
|
||||
}
|
||||
|
||||
/// <summary>启动前诊断:当前配置、解析到的 exe、端口占用等(供 /api/health/simplelite 与启动日志)。</summary>
|
||||
public SimpleLiteDiagnostics GetDiagnostics()
|
||||
{
|
||||
var resolved = ResolveExecutable(_opts.ExecutablePath);
|
||||
var projectionUp = TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(400));
|
||||
return new SimpleLiteDiagnostics(
|
||||
Enabled: _opts.Enabled,
|
||||
ConfiguredExecutablePath: _opts.ExecutablePath ?? "",
|
||||
ConfiguredWorkingDirectory: _opts.WorkingDirectory ?? "",
|
||||
ContentRootPath: _env.ContentRootPath,
|
||||
ResolvedExecutablePath: resolved,
|
||||
ExecutableExists: resolved != null && File.Exists(resolved),
|
||||
IsRunning: IsRunning,
|
||||
LastLaunchMode: LastLaunchMode,
|
||||
ProjectionPort: _opts.ProjectionPort,
|
||||
ProjectionPortReachable: projectionUp,
|
||||
ConfigHint: "编辑 MiGu.Server/appsettings.json 的 SimpleLite 节点(所有环境生效);开发机可叠加 appsettings.Development.json");
|
||||
}
|
||||
|
||||
private string? ResolveExecutable(string configured)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(configured))
|
||||
{
|
||||
var p = Path.IsPathRooted(configured) ? configured : Path.GetFullPath(configured, _env.ContentRootPath);
|
||||
return File.Exists(p) ? p : null;
|
||||
}
|
||||
|
||||
var cwd = _env.ContentRootPath;
|
||||
var staticCandidates = new[]
|
||||
{
|
||||
Path.Combine(cwd, "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "SimpleLite", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
// Migu2.0 与 Simple 并列:Migu2.0/MiGu.Server → ../../Simple/SimpleLite/bin/Debug
|
||||
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Debug", "SimpleLite.exe"),
|
||||
Path.Combine(cwd, "..", "Simple", "SimpleLite", "bin", "Release", "SimpleLite.exe"),
|
||||
};
|
||||
foreach (var c in staticCandidates)
|
||||
{
|
||||
if (File.Exists(c)) return Path.GetFullPath(c);
|
||||
}
|
||||
|
||||
// 沿父目录上行兜底(开发机 cwd 可能是 MiGu.Server/bin/Debug/net8.0/)
|
||||
var dir = new DirectoryInfo(cwd);
|
||||
while (dir != null)
|
||||
{
|
||||
foreach (var sub in new[]
|
||||
{
|
||||
"SimpleLite/bin/Debug/SimpleLite.exe",
|
||||
"SimpleLite/bin/Release/SimpleLite.exe",
|
||||
"Simple/SimpleLite/bin/Debug/SimpleLite.exe",
|
||||
"Simple/SimpleLite/bin/Release/SimpleLite.exe",
|
||||
})
|
||||
{
|
||||
var p = Path.Combine(dir.FullName, sub.Replace('/', Path.DirectorySeparatorChar));
|
||||
if (File.Exists(p)) return Path.GetFullPath(p);
|
||||
}
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool WaitForProjectionReady()
|
||||
{
|
||||
if (_opts.ReadinessTimeoutMs == 0) return false;
|
||||
var deadline = _opts.ReadinessTimeoutMs < 0
|
||||
? DateTime.MaxValue
|
||||
: DateTime.UtcNow.AddMilliseconds(_opts.ReadinessTimeoutMs);
|
||||
var interval = Math.Max(50, _opts.ReadinessPollIntervalMs);
|
||||
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
// 进程已死就别等了 —— 端口永远不会就绪。
|
||||
lock (_sync)
|
||||
{
|
||||
if (_proc is null || _proc.HasExited) return false;
|
||||
}
|
||||
|
||||
if (TryConnect("127.0.0.1", _opts.ProjectionPort, TimeSpan.FromMilliseconds(interval)))
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] projection :{Port} ready", _opts.ProjectionPort);
|
||||
return true;
|
||||
}
|
||||
|
||||
Thread.Sleep(interval);
|
||||
}
|
||||
|
||||
_log.LogWarning("[SimpleLite] projection :{Port} not ready within {Timeout}ms", _opts.ProjectionPort, _opts.ReadinessTimeoutMs);
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryConnect(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
var task = client.ConnectAsync(host, port);
|
||||
return task.Wait(timeout) && client.Connected;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 在 TCP 通的基础上读取 SimpleLite Projection 的强类型 JSON 端点,判别「真的是 SimpleLite」。
|
||||
/// 只接受 2xx 且响应体像 JSON 数组/对象;任意普通 HTTP 服务占用 8222 不再被误认为可复用。
|
||||
/// </summary>
|
||||
private static bool ProbeSimpleLiteHttp(string host, int port, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var httpClient = new System.Net.Http.HttpClient
|
||||
{
|
||||
Timeout = timeout
|
||||
};
|
||||
using var req = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.Get, $"http://{host}:{port}/projection/cars");
|
||||
using var resp = httpClient.Send(req);
|
||||
if (!resp.IsSuccessStatusCode) return false;
|
||||
var body = resp.Content.ReadAsStringAsync().GetAwaiter().GetResult().TrimStart();
|
||||
return body.StartsWith("[", StringComparison.Ordinal) || body.StartsWith("{", StringComparison.Ordinal);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉起 SimpleLite 的结果摘要。
|
||||
/// <list type="bullet">
|
||||
/// <item><c>Started</c>:本次调用后是否处于"已运行"状态(包含 AlreadyRunning / ReusingExisting / Ready / StartedButNotReady)。</item>
|
||||
/// <item><c>Status</c>:状态枚举字符串(见上)。</item>
|
||||
/// <item><c>Detail</c>:技术细节,写入服务端日志。</item>
|
||||
/// <item><c>DisplayMode</c>:本次实际生效的 displayMode(web / web+local / external / null)。
|
||||
/// 与 LaunchMode 业务字段区分:external 表示复用了 MiGu.Server 重启前残留的 SimpleLite,对应模式未知。</item>
|
||||
/// <item><c>Warning</c>:透传给前端登录响应的告警文本,null 表示无需告警。</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public readonly record struct LaunchResult(bool Started, string Status, string Detail,
|
||||
string? DisplayMode = null, string? Warning = null);
|
||||
|
||||
public sealed record SimpleLiteDiagnostics(
|
||||
bool Enabled,
|
||||
string ConfiguredExecutablePath,
|
||||
string ConfiguredWorkingDirectory,
|
||||
string ContentRootPath,
|
||||
string? ResolvedExecutablePath,
|
||||
bool ExecutableExists,
|
||||
bool IsRunning,
|
||||
string? LastLaunchMode,
|
||||
int ProjectionPort,
|
||||
bool ProjectionPortReachable,
|
||||
string ConfigHint);
|
||||
|
||||
// ─── Windows JobObject:父进程被杀时 Job 内所有子进程一并 SIGKILL ────────────────────────
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
|
||||
{
|
||||
public long PerProcessUserTimeLimit;
|
||||
public long PerJobUserTimeLimit;
|
||||
public uint LimitFlags;
|
||||
public UIntPtr MinimumWorkingSetSize;
|
||||
public UIntPtr MaximumWorkingSetSize;
|
||||
public uint ActiveProcessLimit;
|
||||
public long Affinity;
|
||||
public uint PriorityClass;
|
||||
public uint SchedulingClass;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct IO_COUNTERS
|
||||
{
|
||||
public ulong ReadOperationCount;
|
||||
public ulong WriteOperationCount;
|
||||
public ulong OtherOperationCount;
|
||||
public ulong ReadTransferCount;
|
||||
public ulong WriteTransferCount;
|
||||
public ulong OtherTransferCount;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
|
||||
{
|
||||
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
|
||||
public IO_COUNTERS IoInfo;
|
||||
public UIntPtr ProcessMemoryLimit;
|
||||
public UIntPtr JobMemoryLimit;
|
||||
public UIntPtr PeakProcessMemoryUsed;
|
||||
public UIntPtr PeakJobMemoryUsed;
|
||||
}
|
||||
|
||||
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
|
||||
private const int JobObjectExtendedLimitInformation = 9;
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
|
||||
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string? lpName);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool SetInformationJobObject(IntPtr hJob, int infoType, IntPtr lpInfo, uint cbInfoLength);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool CloseHandle(IntPtr hObject);
|
||||
|
||||
private void AttachToJobObject(Process child)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
_log.LogInformation("[SimpleLite] JobObject skipped on non-Windows; falling back to ProcessExit-only cleanup");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_job == IntPtr.Zero)
|
||||
{
|
||||
_job = CreateJobObject(IntPtr.Zero, null);
|
||||
if (_job == IntPtr.Zero)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] CreateJobObject failed; child may outlive MiGu.Server");
|
||||
return;
|
||||
}
|
||||
|
||||
var info = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION();
|
||||
info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
|
||||
int len = Marshal.SizeOf(typeof(JOBOBJECT_EXTENDED_LIMIT_INFORMATION));
|
||||
IntPtr ptr = Marshal.AllocHGlobal(len);
|
||||
try
|
||||
{
|
||||
Marshal.StructureToPtr(info, ptr, false);
|
||||
if (!SetInformationJobObject(_job, JobObjectExtendedLimitInformation, ptr, (uint)len))
|
||||
_log.LogWarning("[SimpleLite] SetInformationJobObject failed; child may outlive MiGu.Server");
|
||||
}
|
||||
finally { Marshal.FreeHGlobal(ptr); }
|
||||
}
|
||||
|
||||
if (!AssignProcessToJobObject(_job, child.Handle))
|
||||
_log.LogWarning("[SimpleLite] AssignProcessToJobObject failed; child may outlive MiGu.Server");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_log.LogWarning("[SimpleLite] JobObject setup error: {Type}: {Msg}", ex.GetType().Name, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
namespace MiGu.Server.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// MiGu.Server 启动后由「平台登录」按 LaunchMode 拉起的 SimpleLite 子进程配置。
|
||||
///
|
||||
/// 会话 N+1(启动反转):原架构是 SimpleLite 启动 → 拉 MiGu.Server;现在反过来:
|
||||
/// MiGu.Server 作为主入口启动 → 登录页选 LaunchMode → 后端拉起 SimpleLite.exe,
|
||||
/// 通过 <c>--display-mode</c> 参数把 web / web+local 透传给 SimpleLite 的 Configuration。
|
||||
///
|
||||
/// 绑定 <c>appsettings.json:SimpleLite</c>。
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteOptions
|
||||
{
|
||||
/// <summary>主开关。false = 永不拉起;登录无论选什么 LaunchMode 都不会启动子进程,用于「只跑 Platform 调试」场景。</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// SimpleLite 可执行文件绝对/相对路径(相对 MiGu.Server 工作目录)。留空则按以下顺序自动探测:
|
||||
/// 1) <c>./SimpleLite.exe</c>(合并发布布局:SimpleLite 与 MiGu.Server 同目录)
|
||||
/// 2) <c>./SimpleLite/SimpleLite.exe</c>
|
||||
/// 3) <c>../SimpleLite.exe</c>(MiGu.Server 部署到子目录的常见布局)
|
||||
/// 4) <c>../SimpleLite/bin/Debug/SimpleLite.exe</c>(开发态:Visual Studio 默认输出)
|
||||
/// 5) <c>../SimpleLite/bin/Release/SimpleLite.exe</c>
|
||||
/// 6) 沿父目录上行寻找 <c>SimpleLite/bin/{Debug|Release}/SimpleLite.exe</c>
|
||||
/// </summary>
|
||||
public string ExecutablePath { get; set; } = "";
|
||||
|
||||
/// <summary>留空时取 <see cref="ExecutablePath"/> 所在目录。SimpleLite 在 CWD 读写 simple.json / imgui.ini,CWD 选错会出意外。</summary>
|
||||
public string WorkingDirectory { get; set; } = "";
|
||||
|
||||
/// <summary>附加命令行参数(拼在 <c>--display-mode=xxx</c> 之后)。常用于本地调试时强制 autoload 某场景。</summary>
|
||||
public string Arguments { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 子进程启动后阻塞等待 Projection (:8222) 端口就绪的最长毫秒数。
|
||||
/// 0 = 不等待(登录立即返回,前端可能还连不上 SimpleLite WebApi);
|
||||
/// 负值 = 无限等待(直到子进程退出或就绪)。
|
||||
/// </summary>
|
||||
public int ReadinessTimeoutMs { get; set; } = 8000;
|
||||
|
||||
/// <summary>每隔多少毫秒 poll 一次 Projection 端口可达性。</summary>
|
||||
public int ReadinessPollIntervalMs { get; set; } = 250;
|
||||
|
||||
/// <summary>SimpleLite Projection WebApi 监听端口。默认与 SimpleLite Configuration 的 `port` 一致。用于就绪检测。</summary>
|
||||
public int ProjectionPort { get; set; } = 8222;
|
||||
|
||||
/// <summary>
|
||||
/// 是否把 SimpleLite 绑定到 MiGu.Server 生命周期,默认 <b>false</b>(会话 N+2 用户反馈)。
|
||||
///
|
||||
/// 设计原则:SimpleLite 与 MiGu.Server 是「两个独立程序」,Platform 只是登录后顺手拉起 SimpleLite;
|
||||
/// MiGu.Server 关闭不应该带走 SimpleLite,反之亦然。所以 FollowParent 默认 false:
|
||||
/// - 子进程走 <c>UseShellExecute=true</c> 创建独立进程组 + 独立控制台窗口;
|
||||
/// - 不挂 JobObject,不在 ApplicationStopping / ProcessExit 时 kill 子进程;
|
||||
/// - SimpleLite 退出由用户自己负责(关窗口 / 任务管理器 / 调度内核异常退出)。
|
||||
///
|
||||
/// true 仍可用:会启动 Windows JobObject 父子绑定(仅 Windows 有效)+ 注册 ApplicationStopping 软关闭。
|
||||
/// 一般只在临时联调期 / CI 流水线想自动清理时打开。
|
||||
/// </summary>
|
||||
public bool FollowParent { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>MiGu.Server</RootNamespace>
|
||||
<AssemblyName>MiGu.Server</AssemblyName>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<InvariantGlobalization>false</InvariantGlobalization>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.10" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.7.3" />
|
||||
<PackageReference Include="Yarp.ReverseProxy" Version="2.2.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.10" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="data\.gitkeep" Condition="Exists('data\.gitkeep')" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,223 @@
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Configs;
|
||||
using MiGu.Server.Launcher;
|
||||
using Yarp.ReverseProxy.Transforms;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// S2 根治 (会话45):让 MiGu.Server 启动时自动优先把 frontends/apps/simple-platform-vue/dist/
|
||||
// 作为 WebRoot —— 解决「每次 vite build → wwwroot/index.html 都被 hash 漂移 → 提一个噪音 commit」
|
||||
// 的死循环。
|
||||
//
|
||||
// 探测策略:从 ContentRootPath 出发向上逐级找 frontends/apps/simple-platform-vue/dist/index.html,
|
||||
// 找到则将 WebRootPath 改指到 dist;找不到则保持默认 wwwroot/(用于「克隆后没跑 build」或
|
||||
// 生产部署机不带 node 的场景,wwwroot/ 由 build-platform-frontend.bat 的 robocopy /MIR 兜底)。
|
||||
//
|
||||
// 影响:
|
||||
// - dev 模式:跑过一次 `pnpm build` 后无需 robocopy,重启 MiGu.Server 即生效;
|
||||
// 也不再需要 commit wwwroot/index.html。
|
||||
// - 部署:CI/CD 走 build-platform-frontend.bat 把 dist 同步到 wwwroot/,dist 此时不存在
|
||||
// 于发布产物中 → 自动 fallback 到 wwwroot/。行为与改造前一致。
|
||||
{
|
||||
static string? FindDistRoot(string startDir)
|
||||
{
|
||||
var dir = new DirectoryInfo(startDir);
|
||||
while (dir != null)
|
||||
{
|
||||
var candidate = Path.Combine(dir.FullName, "frontends", "apps", "simple-platform-vue", "dist", "index.html");
|
||||
if (File.Exists(candidate)) return Path.GetDirectoryName(candidate);
|
||||
dir = dir.Parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
var dist = FindDistRoot(builder.Environment.ContentRootPath);
|
||||
if (dist != null)
|
||||
{
|
||||
builder.Environment.WebRootPath = dist;
|
||||
Console.WriteLine($"[MiGu.Server] WebRoot -> dist: {dist}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine($"[MiGu.Server] WebRoot -> wwwroot (dist not found): {builder.Environment.WebRootPath}");
|
||||
}
|
||||
}
|
||||
|
||||
// 控制器 + JSON 默认大小写、忽略 null
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(opt =>
|
||||
{
|
||||
opt.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
|
||||
opt.JsonSerializerOptions.DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull;
|
||||
opt.JsonSerializerOptions.WriteIndented = false;
|
||||
});
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen(c =>
|
||||
{
|
||||
c.SwaggerDoc("v1", new() { Title = "MiGu.Server", Version = "v1", Description = "Simple-FR 平台后端骨架(含 YARP 反代 SimpleLite 8222)。" });
|
||||
// Swagger 里挂 Bearer 输入框,便于手工测带鉴权的端点。
|
||||
c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme
|
||||
{
|
||||
Name = "Authorization",
|
||||
Description = "JWT bearer。值: \"Bearer {token}\"",
|
||||
In = ParameterLocation.Header,
|
||||
Type = SecuritySchemeType.ApiKey,
|
||||
Scheme = "Bearer"
|
||||
});
|
||||
c.AddSecurityRequirement(new OpenApiSecurityRequirement
|
||||
{
|
||||
{
|
||||
new OpenApiSecurityScheme
|
||||
{
|
||||
Reference = new OpenApiReference { Type = ReferenceType.SecurityScheme, Id = "Bearer" }
|
||||
},
|
||||
Array.Empty<string>()
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// CORS(Vite dev 5173 与 MiGu.Server 8080 跨域)
|
||||
var origins = builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? Array.Empty<string>();
|
||||
builder.Services.AddCors(opts => opts.AddDefaultPolicy(p =>
|
||||
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod().AllowCredentials()));
|
||||
|
||||
// ─── AR-3 / AR-5:JWT + Cookie 双轨鉴权 ─────────────────────────────────────────
|
||||
//
|
||||
// 设计:
|
||||
// - access token 同时通过 Authorization: Bearer header 与 httpOnly Cookie 两路传输;
|
||||
// - 旧 SPA 还在用 localStorage.token + Bearer,逐步迁移到 Cookie;过渡期两路都接受;
|
||||
// - JwtIssuer 集中颁发 + 验签;secret 来自 appsettings:Jwt:Secret 或环境变量
|
||||
// PLATFORM__JWT__SECRET,占位值会被运行时随机化并强制告警。
|
||||
// - InternalTokenStore 管理 SimpleLite 8222 ↔ MiGu.Server 之间的 X-Platform-Internal-Token
|
||||
// 共享密钥(YARP transform 自动追加)。
|
||||
builder.Services.AddSingleton<UserStore>();
|
||||
builder.Services.AddSingleton<JwtIssuer>(sp =>
|
||||
{
|
||||
var config = sp.GetRequiredService<IConfiguration>();
|
||||
var logger = sp.GetRequiredService<ILogger<JwtIssuer>>();
|
||||
var secret = config["Jwt:Secret"] ?? JwtIssuer.PlaceholderSecret;
|
||||
var issuer = config["Jwt:Issuer"] ?? "MiGu.Server";
|
||||
var audience = config["Jwt:Audience"] ?? "platform.client";
|
||||
var lifetimeMinutes = int.TryParse(config["Jwt:LifetimeMinutes"], out var m) && m > 0 ? m : 24 * 60;
|
||||
return new JwtIssuer(secret, issuer, audience, TimeSpan.FromMinutes(lifetimeMinutes), logger);
|
||||
});
|
||||
builder.Services.AddSingleton<InternalTokenStore>();
|
||||
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(opt =>
|
||||
{
|
||||
// TokenValidationParameters 在第一次解析请求时从 JwtIssuer 拿,避免 ctor 顺序耦合。
|
||||
opt.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||
{
|
||||
// 完整参数在 OnMessageReceived 里替换为 JwtIssuer.BuildValidationParameters()
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateIssuerSigningKey = false,
|
||||
ValidateLifetime = false,
|
||||
};
|
||||
opt.Events = new JwtBearerEvents
|
||||
{
|
||||
OnMessageReceived = ctx =>
|
||||
{
|
||||
// 优先从 Authorization: Bearer 取;没有再从 Cookie 取。
|
||||
if (string.IsNullOrEmpty(ctx.Token))
|
||||
{
|
||||
var cookie = ctx.Request.Cookies["simple.auth.token"];
|
||||
if (!string.IsNullOrEmpty(cookie)) ctx.Token = cookie;
|
||||
}
|
||||
// 用真实 JwtIssuer 参数替换占位 ValidationParameters。
|
||||
var issuer = ctx.HttpContext.RequestServices.GetRequiredService<JwtIssuer>();
|
||||
ctx.Options.TokenValidationParameters = issuer.BuildValidationParameters();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization(opts =>
|
||||
{
|
||||
// Platform scope:完整管理端权限,对应 admin 账号。
|
||||
opts.AddPolicy("PlatformScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "Platform"));
|
||||
// RCSMonitor scope:运营白名单。
|
||||
opts.AddPolicy("MonitorScope", p => p.RequireAuthenticatedUser().RequireClaim("scope", "RCSMonitor"));
|
||||
// 任一登录用户。
|
||||
opts.AddPolicy("AnyAuthed", p => p.RequireAuthenticatedUser());
|
||||
});
|
||||
|
||||
// YARP + transform:把 Platform 内部 token 透传给 SimpleLite 8222(AR-1/AR-2 配套)。
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"))
|
||||
.AddTransforms(tctx =>
|
||||
{
|
||||
// 只对 sl-route 注入 internal token;vrender-route(webVRender iframe 静态资源)不需要。
|
||||
if (tctx.Route.RouteId != "sl-route") return;
|
||||
tctx.AddRequestTransform(rt =>
|
||||
{
|
||||
var store = rt.HttpContext.RequestServices.GetRequiredService<InternalTokenStore>();
|
||||
rt.ProxyRequest.Headers.Remove("X-Platform-Internal-Token");
|
||||
rt.ProxyRequest.Headers.Add("X-Platform-Internal-Token", store.Token);
|
||||
return ValueTask.CompletedTask;
|
||||
});
|
||||
});
|
||||
|
||||
// 单例配置仓库(内存 + JSON 文件持久化占位)
|
||||
builder.Services.AddSingleton<ConfigStore>();
|
||||
|
||||
// 会话 N+1(启动反转):把 SimpleLite 子进程拉起器接入 DI;AuthController 登录成功后按 LaunchMode 调 MaybeStart。
|
||||
// 配置段绑定 appsettings.json:SimpleLite,可被环境变量 SIMPLELITE__XXX 覆盖。
|
||||
builder.Services.Configure<SimpleLiteOptions>(builder.Configuration.GetSection("SimpleLite"));
|
||||
builder.Services.AddSingleton<SimpleLiteLauncher>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// 启动期主动构造 JwtIssuer / InternalTokenStore:让 secret 校验日志在请求来之前打印。
|
||||
_ = app.Services.GetRequiredService<JwtIssuer>();
|
||||
_ = app.Services.GetRequiredService<InternalTokenStore>();
|
||||
// 主动实例化 SimpleLiteLauncher,让 ProcessExit 钩子尽早注册(MiGu.Server 异常退出时 SimpleLite 也会被清理)。
|
||||
var simpleLiteLauncher = app.Services.GetRequiredService<SimpleLiteLauncher>();
|
||||
{
|
||||
var sl = simpleLiteLauncher.GetDiagnostics();
|
||||
app.Logger.LogInformation(
|
||||
"[MiGu.Server] SimpleLite: Enabled={Enabled}, ConfiguredPath={Cfg}, Resolved={Resolved}, Exists={Exists}, Port:{Port} reachable={PortUp}. 配置见 appsettings.json → SimpleLite",
|
||||
sl.Enabled, sl.ConfiguredExecutablePath, sl.ResolvedExecutablePath ?? "(未找到)", sl.ExecutableExists,
|
||||
sl.ProjectionPort, sl.ProjectionPortReachable);
|
||||
}
|
||||
|
||||
// MiGu.Server 停机时是否带走 SimpleLite,由 SimpleLiteLauncher.Dispose 内部按 FollowParent 决定:
|
||||
// - 会话 N+2 起 FollowParent=false 默认值 → Dispose 仅释放本地引用,不 kill 子进程(独立程序语义);
|
||||
// - 仅当用户显式 opt-in FollowParent=true 时,Dispose 才会 kill 子进程 + 关闭 JobObject。
|
||||
app.Lifetime.ApplicationStopping.Register(() =>
|
||||
{
|
||||
try { app.Services.GetRequiredService<SimpleLiteLauncher>().Dispose(); }
|
||||
catch { /* shutdown best-effort */ }
|
||||
});
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
|
||||
// 静态资源:MiGu.Server/wwwroot 下可同时存放 admin / monitor / index 三份 SPA
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
// 鉴权 / 授权管道必须放在 MapControllers 之前;CORS 之后。
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
|
||||
// YARP:/api/sl/* → :8222;/vr/* → :8223
|
||||
app.MapReverseProxy();
|
||||
|
||||
// SPA fallback:单一合并 Vue 工程(vue-router 处理 /admin/* /monitor/* /login /status)
|
||||
// 所有非 API、非静态资源的路径都返回 wwwroot/index.html
|
||||
app.MapFallbackToFile("index.html");
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"MiGu.Server": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://0.0.0.0:8080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
# MiGu.Server
|
||||
|
||||
> 「**迷毂 · 智能调度平台**」的后端骨架(ASP.NET Core 8 + YARP)。
|
||||
> 对应 [ARCHITECTURE.md](ARCHITECTURE.md) v1.6 §1.1 中提到的「Platform 管理端」后端可执行文件。
|
||||
>
|
||||
> **会话 N+1(启动反转):现在 `MiGu.Server.exe` 是主入口**。
|
||||
> 用户先启动 MiGu.Server,浏览器登录页选择「启动模式」(本地+Web / 仅Web),
|
||||
> 后端 `AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(launchMode)` 按所选模式拉起 `SimpleLite.exe`,
|
||||
> 通过 `--display-mode=web|web+local` 透传给 SimpleLite 的 `Configuration.displayMode`。
|
||||
>
|
||||
> 工程名 `MiGu.Server` / 程序集 `MiGu.Server.dll` 保持稳定,仅用户可见前端文案改为「迷毂」。
|
||||
|
||||
## 克隆后首次部署速查(必读)
|
||||
|
||||
本仓库为 **Migu2.0**,已包含预构建的 `wwwroot/` 前端,克隆后可直接编译运行 MiGu.Server。
|
||||
|
||||
```pwsh
|
||||
# 1) 编译 MiGu.Server
|
||||
cd MiGu.Server
|
||||
dotnet build MiGu.Server.csproj -c Debug
|
||||
|
||||
# 2) 启动(登录后按所选模式拉起 SimpleLite)
|
||||
dotnet run
|
||||
# 或:.\bin\Debug\net8.0\MiGu.Server.exe
|
||||
```
|
||||
|
||||
**更新前端**:在 Simple 仓库执行 `build-platform-frontend.bat`,将 `frontends/apps/simple-platform-vue/dist/` 同步到本目录 `wwwroot/`。
|
||||
|
||||
## 配置 SimpleLite 路径(必读)
|
||||
|
||||
> **配置文件位置(无 Web 界面):**
|
||||
> **`MiGu.Server/appsettings.json`** → 搜索 **`"SimpleLite"`** 节点。
|
||||
> 登录页选「本地 + Web / 仅 Web」后,后端在此配置的路径拉起 `SimpleLite.exe`。
|
||||
> 启动后可在浏览器打开 **`http://localhost:8080/api/health/simplelite`** 查看路径是否解析成功。
|
||||
|
||||
登录成功后,MiGu.Server 会按所选启动模式拉起 **SimpleLite.exe**。默认已在 `appsettings.json` 写好与 Simple 仓库并列的相对路径;开发机可用 `appsettings.Development.json` 覆盖为绝对路径。
|
||||
|
||||
### 本机开发(与 Simple 仓库并列)
|
||||
|
||||
仓库默认已在 `appsettings.Development.json` 中写好:
|
||||
|
||||
```json
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
||||
"ProjectionPort": 8222,
|
||||
"ReadinessTimeoutMs": 8000,
|
||||
"FollowParent": false
|
||||
}
|
||||
```
|
||||
|
||||
请先编译 SimpleLite(在 Simple 仓库):
|
||||
|
||||
```pwsh
|
||||
cd ..\Simple
|
||||
dotnet build SimpleLite\SimpleLite.csproj -c Debug
|
||||
```
|
||||
|
||||
若你的 Simple 不在上述绝对路径,可改为**相对路径**(相对 `MiGu.Server` 工作目录):
|
||||
|
||||
```json
|
||||
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug"
|
||||
```
|
||||
|
||||
### 配置项说明
|
||||
|
||||
| 键 | 含义 |
|
||||
|----|------|
|
||||
| `Enabled` | `false` 时永不拉起 SimpleLite(只调试平台 UI) |
|
||||
| `ExecutablePath` | `SimpleLite.exe` 绝对或相对路径;留空则自动探测(见 `Launcher/SimpleLiteOptions.cs`) |
|
||||
| `WorkingDirectory` | 子进程工作目录;留空则用 exe 所在目录(读写 `simple.json` / `imgui.ini`) |
|
||||
| `ProjectionPort` | 就绪检测端口,默认 `8222` |
|
||||
| `ReadinessTimeoutMs` | 登录后等待 SimpleLite WebAPI 就绪的最长时间(毫秒) |
|
||||
| `FollowParent` | `true` 时 MiGu.Server 退出会结束 SimpleLite;默认 `false`(两进程独立) |
|
||||
|
||||
### 环境变量覆盖
|
||||
|
||||
```pwsh
|
||||
$env:SimpleLite__ExecutablePath = "D:\apps\SimpleLite.exe"
|
||||
$env:SimpleLite__WorkingDirectory = "D:\apps"
|
||||
$env:SimpleLite__Enabled = "true"
|
||||
```
|
||||
|
||||
### 生产 / 合并发布
|
||||
|
||||
将 `SimpleLite.exe` 与依赖 DLL 放到 `MiGu.Server` 同目录,并清空 `ExecutablePath`(走自动探测 `./SimpleLite.exe`),或在 `appsettings.Production.json` 写死部署路径。
|
||||
|
||||
浏览器访问 `http://localhost:8080/login`:
|
||||
1. 用户名 / 密码(`appsettings.json:Auth.Users` 默认 admin/admin、ops/ops);
|
||||
2. 选 scope(管理员 / 运营);
|
||||
3. 选 **SimpleLite 启动模式**:
|
||||
- **本地 + Web** → 透传 `--display-mode=web+local`,桌面 ImGui 窗口 + 浏览器同时启动;
|
||||
- **仅 Web** → 透传 `--display-mode=web`,只起 WebTerminal,不弹本地窗口;
|
||||
4. 点登录。`AuthController.Login` 调用 `SimpleLiteLauncher.MaybeStart(...)` 阻塞等 Projection (`:8222`) 就绪后返回 LoginResponse。
|
||||
|
||||
> 如果只想跑 MiGu.Server 单进程调试(不拉 SimpleLite),把 `appsettings.json:SimpleLite.Enabled` 改为 `false` 即可。
|
||||
|
||||
## 安全须知(生产部署必读)
|
||||
|
||||
> **重要:以下默认值仅供本地开发,切勿原样用于生产环境。**
|
||||
>
|
||||
> - `appsettings.json:Auth.Users` 内置 `admin/admin`、`ops/ops` 为弱口令演示账号;
|
||||
> - `appsettings.json:Jwt.Secret`、`Internal.Token` 为占位符 `REPLACE_ME`。
|
||||
>
|
||||
> 生产部署务必通过**环境变量**或 `appsettings.Production.json` / 密钥管理覆盖
|
||||
> (ASP.NET Core 配置优先级:环境变量 > `appsettings.{Environment}.json` > `appsettings.json`)。
|
||||
> 环境变量示例(`__` 双下划线表示配置层级):
|
||||
>
|
||||
> ```pwsh
|
||||
> $env:Auth__Users__admin__Password = "<强密码>"
|
||||
> $env:Jwt__Secret = "<不少于 32 字节的随机串>"
|
||||
> $env:Internal__Token = "<随机串>"
|
||||
> ```
|
||||
>
|
||||
> 详见代码审核 ISSUE-03(`Doc/CODE_REVIEW_ISSUES_2026-05-29.md`)。
|
||||
>
|
||||
> **SimpleLite 8222 内部 API(RV-04)**:默认 `simple.json:platform.allowLoopbackBypass=true`,本机回环请求免 token;**多租户 / 共享主机的生产环境建议设为 `false`**,强制所有请求携带 `X-Platform-Internal-Token`(配合 `platform.internalToken` 或 `MiGu.Server/data/.internal-token`)。详见复审 `Doc/CODE_REVIEW_WEEK_2026-05-29.md` RV-04。
|
||||
|
||||
## 能力清单(本轮)
|
||||
|
||||
- Kestrel 监听 `:8080`(HTTP);
|
||||
- 静态托管:启动时自动探测 WebRoot ——
|
||||
- 开发模式:仓库内 `frontends/apps/simple-platform-vue/dist/` 存在则直接挂为 WebRoot(`pnpm build` 后立即生效,无需 robocopy 到 wwwroot);
|
||||
- 兜底/部署模式:找不到 dist 时回退到 `wwwroot/`(由 `build-platform-frontend.bat` 的 robocopy /MIR 填充);
|
||||
- SPA fallback 让 `/admin/*` `/monitor/*` `/login` `/status` 等所有非 API 路径都回退到 `index.html`,由 vue-router 接管;
|
||||
- YARP 反向代理:
|
||||
- `/api/sl/{**catch-all}` → `http://127.0.0.1:8222/`(SimpleLite WebAPI;当前 SimpleLite 未启 WebAPI 时返回 502);
|
||||
- `/vr/{**catch-all}` → `http://127.0.0.1:8223/`(webVRender;可用于同源 iframe 解决 X-Frame-Options 限制);
|
||||
- 14 维度配置中心占位(对齐 §9):内存 + `data/config-{section}.json` 持久化;
|
||||
- Mock 鉴权:`/api/auth/login` 返回 Mock JWT + `EffectivePermissions`;LoginRequest 新增 `launchMode: "WebOnly" | "DesktopAndWeb"` 字段,登录成功后 `SimpleLiteLauncher` 据此拉起子进程;
|
||||
- SimpleLite 子进程编排:`Launcher/SimpleLiteLauncher.cs` 幂等 / Stdout 转发 / Windows JobObject 父子绑定 / 端口就绪等待,配置见 `appsettings.json:SimpleLite`;
|
||||
- 运维白名单网关占位:`/api/sl/ops/execute`、`/api/sl/ops/audits`;
|
||||
- 投影 API 占位:`/api/projection/{sites,tracks,cars,missions}`;
|
||||
- 健康检查:`/api/health`;
|
||||
- Swagger:开发环境下 `/swagger`。
|
||||
|
||||
> 不在本轮范围:真实 SimpleLite WebAPI、SystemMission 拉起、真实 JWT/RBAC、SQLite/EF Core 持久层。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
MiGu.Server/
|
||||
├── MiGu.Server.csproj
|
||||
├── Program.cs # Kestrel + YARP + CORS + StaticFiles + SPA fallback
|
||||
├── appsettings.json # YARP 路由 + CORS 白名单
|
||||
├── appsettings.Development.json
|
||||
├── Properties/launchSettings.json
|
||||
├── Controllers/
|
||||
│ ├── AuthController.cs # /api/auth/login | /api/auth/logout
|
||||
│ ├── ConfigController.cs # GET/PUT /api/config[/{section}]
|
||||
│ ├── ProjectionController.cs # /api/projection/{sites,tracks,cars,missions}
|
||||
│ ├── OpsController.cs # /api/sl/ops/{execute,audits}
|
||||
│ └── HealthController.cs # /api/health
|
||||
├── Configs/ # 与 ARCHITECTURE.md §9 一一对齐的强类型 record
|
||||
│ ├── SystemConfig.cs
|
||||
│ ├── ExternalIntegrations.cs
|
||||
│ ├── RoutingPolicy.cs
|
||||
│ ├── VehicleMaintenancePolicy.cs
|
||||
│ ├── ChargePolicy.cs
|
||||
│ ├── TaskAllocationPolicy.cs
|
||||
│ ├── TrafficRule.cs
|
||||
│ ├── DeviceManagementConfig.cs
|
||||
│ ├── FleetLifecycleConfig.cs
|
||||
│ ├── ScenarioTemplateConfig.cs
|
||||
│ ├── LocationManagement.cs
|
||||
│ ├── OpsConfig.cs
|
||||
│ ├── CustomWidget.cs
|
||||
│ ├── EffectivePermissions.cs
|
||||
│ └── ConfigStore.cs # 内存 + JSON 文件持久化(占位)
|
||||
├── data/ # 运行时生成的 config-{section}.json
|
||||
└── wwwroot/ # 部署兜底(dev 模式优先用 frontends/.../dist;assets/ 与 index.html 已 ignore)
|
||||
```
|
||||
|
||||
## 独立启动(仅当你不通过 SimpleLite 自启时)
|
||||
|
||||
```pwsh
|
||||
# 1) 还原 + 编译 + 运行
|
||||
cd MiGu.Server
|
||||
dotnet run
|
||||
|
||||
# 等价的 build + run 一键脚本:
|
||||
.\build-and-run.bat # Debug
|
||||
.\build-and-run.bat release # Release
|
||||
|
||||
# 2) 验证
|
||||
# 打开 http://localhost:8080/ 欢迎页 / 平台前端
|
||||
# 打开 http://localhost:8080/swagger API 文档
|
||||
# 打开 http://localhost:8080/api/health 健康检查
|
||||
# 打开 http://localhost:8080/api/config/system 系统级配置
|
||||
```
|
||||
|
||||
或在 IDE 中以 `MiGu.Server` 作为启动项目。
|
||||
|
||||
> 启动时控制台会打印 `[MiGu.Server] WebRoot -> dist: ...` 或 `WebRoot -> wwwroot ...`,提示当前使用哪一份产物。
|
||||
> 单独启动且既没有跑过 `pnpm build` 也没有跑过 `build-platform-frontend.bat` 时,会看到 SPA fallback 找不到 `index.html`。
|
||||
> 仓库根 `build-platform-frontend.bat` 仍可用于「打部署包」场景(把 dist robocopy /MIR 同步到 wwwroot)。
|
||||
> SimpleLite 自启 MiGu.Server 时,Program.cs 会从 `bin\<Config>\net8.0\` 沿目录向上找 dist;
|
||||
> 找到则使用源码区 dist,找不到则使用 bin 同级的 wwwroot——两种启动方式共用同一份前端产物。
|
||||
|
||||
## 与前端联调
|
||||
|
||||
- 开发模式(前端在 Simple 仓库):
|
||||
- 前端:`cd ../Simple/frontends && pnpm dev`(`:5173`),Vite 代理 `/api` 至 `http://127.0.0.1:8080`;
|
||||
- 后端:`cd MiGu.Server && dotnet run`(`:8080`);
|
||||
- 浏览器访问 `http://localhost:5173/login`。
|
||||
- 生产/同源模式(本仓库 `wwwroot/`):
|
||||
- 默认使用 `MiGu.Server/wwwroot/`(已随仓库提交构建产物);
|
||||
- 若上级目录存在 `frontends/apps/simple-platform-vue/dist/`,Program.cs 会优先挂 dist;
|
||||
- 浏览器访问 `http://localhost:8080/login` 等路径由 vue-router 解析。
|
||||
|
||||
## YARP 与 SimpleLite
|
||||
|
||||
YARP 路由配置见 `appsettings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
"sl-route": { "Match": { "Path": "/api/sl/{**catch-all}" }, "ClusterId": "sl-cluster" },
|
||||
"vrender-route": { "Match": { "Path": "/vr/{**catch-all}" }, "ClusterId": "vrender-cluster" }
|
||||
},
|
||||
"Clusters": {
|
||||
"sl-cluster": { "Destinations": { "sl1": { "Address": "http://127.0.0.1:8222/" } } },
|
||||
"vrender-cluster": { "Destinations": { "vr1": { "Address": "http://127.0.0.1:8223/" } } }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `/api/sl/ops/{execute,audits}` 在本工程被 `OpsController` 显式接住(更具体的路由),用于占位测试;
|
||||
- 其他 `/api/sl/*` 由 YARP 透传到 SimpleLite `:8222`,SimpleLite 未启时会得到 502/连接被拒。
|
||||
|
||||
## iframe 嵌入 webVRender 的两种方式
|
||||
|
||||
1. **直连**(默认):前端 iframe 指向 `http://localhost:8223/?scope=...&token=...&ro=...`,跨源;
|
||||
2. **同源代理**(可选):iframe 指向 `http://localhost:8080/vr/?scope=...`,由本工程 YARP 反代到 8223,
|
||||
可规避 X-Frame-Options 等限制。前端可通过 `Workspace3D` 组件的 `host` prop 传 `localhost:8080/vr` 切换。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- [ARCHITECTURE.md](ARCHITECTURE.md) — v1.6 总体架构(§4 进程拓扑、§6.3 YARP 配置、§9 配置中心、§10 交互序列、§17 视觉规范)
|
||||
- [(见 Simple 仓库)frontends/README.md]((见 Simple 仓库)frontends/README.md) — 前端启动与联调说明(含「迷毂」品牌与紫色主题约定)
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Information",
|
||||
"Yarp": "Debug"
|
||||
}
|
||||
},
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "E:\\Work\\Core\\Simple-FR\\Simple\\SimpleLite\\bin\\Debug",
|
||||
"ProjectionPort": 8222,
|
||||
"ReadinessTimeoutMs": 8000,
|
||||
"FollowParent": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Yarp": "Information"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"Cors": {
|
||||
"Origins": [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173"
|
||||
]
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "REPLACE_ME",
|
||||
"Issuer": "migu.server",
|
||||
"Audience": "migu.client",
|
||||
"LifetimeMinutes": 1440
|
||||
},
|
||||
"Internal": {
|
||||
"Token": "REPLACE_ME"
|
||||
},
|
||||
"_comment_SimpleLite": "登录后拉起 SimpleLite;改路径请编辑下方 SimpleLite 节点。诊断: http://localhost:8080/api/health/simplelite",
|
||||
"SimpleLite": {
|
||||
"Enabled": true,
|
||||
"ExecutablePath": "..\\..\\Simple\\SimpleLite\\bin\\Debug\\SimpleLite.exe",
|
||||
"WorkingDirectory": "..\\..\\Simple\\SimpleLite\\bin\\Debug",
|
||||
"Arguments": "",
|
||||
"ReadinessTimeoutMs": 15000,
|
||||
"ReadinessPollIntervalMs": 250,
|
||||
"ProjectionPort": 8222,
|
||||
"FollowParent": false
|
||||
},
|
||||
"Auth": {
|
||||
"Users": {
|
||||
"admin": { "Password": "admin" },
|
||||
"ops": { "Password": "ops" }
|
||||
}
|
||||
},
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
"sl-route": {
|
||||
"ClusterId": "sl-cluster",
|
||||
"Match": { "Path": "/api/sl/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/api/sl" }
|
||||
]
|
||||
},
|
||||
"vrender-route": {
|
||||
"ClusterId": "vrender-cluster",
|
||||
"Match": { "Path": "/vr/{**catch-all}" },
|
||||
"Transforms": [
|
||||
{ "PathRemovePrefix": "/vr" }
|
||||
]
|
||||
}
|
||||
},
|
||||
"Clusters": {
|
||||
"sl-cluster": {
|
||||
"Destinations": {
|
||||
"sl1": { "Address": "http://127.0.0.1:8222/" }
|
||||
}
|
||||
},
|
||||
"vrender-cluster": {
|
||||
"Destinations": {
|
||||
"vr1": { "Address": "http://127.0.0.1:8223/" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
setlocal ENABLEDELAYEDEXPANSION
|
||||
|
||||
REM ==========================================================
|
||||
REM MiGu.Server build and run (one-click)
|
||||
REM - default Debug; pass "release" / "-r" / "/r" for Release
|
||||
REM - listens on http://0.0.0.0:8080 (see Properties\launchSettings.json)
|
||||
REM - YARP proxies: /api/sl/* -> :8222 ; /vr/* -> :8223
|
||||
REM ==========================================================
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "CONFIG=Debug"
|
||||
if /I "%~1"=="release" set "CONFIG=Release"
|
||||
if /I "%~1"=="-r" set "CONFIG=Release"
|
||||
if /I "%~1"=="/r" set "CONFIG=Release"
|
||||
|
||||
echo ============================================================
|
||||
echo MiGu.Server 生成 + 启动 ( Config = %CONFIG% )
|
||||
echo 目录: %CD%
|
||||
echo ============================================================
|
||||
echo.
|
||||
|
||||
where dotnet >nul 2>&1
|
||||
if errorlevel 1 (
|
||||
echo [错误] 未找到 dotnet ^( .NET 8 SDK ^)。请先安装 .NET 8 SDK 并重新打开终端。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo === [1/2] dotnet build ===
|
||||
dotnet build "MiGu.Server.csproj" -c %CONFIG% --nologo
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo *** 构建失败,已停止。请检查上方编译错误。 ***
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo === [2/2] dotnet run ===
|
||||
echo [提示] Ctrl+C 终止;浏览器访问 http://localhost:8080/ ^(Swagger: /swagger^)
|
||||
echo.
|
||||
|
||||
dotnet run --project "MiGu.Server.csproj" -c %CONFIG% --no-build --no-restore
|
||||
set "RUN_EC=%errorlevel%"
|
||||
|
||||
echo.
|
||||
if not "%RUN_EC%"=="0" (
|
||||
echo *** MiGu.Server 退出,errorlevel=%RUN_EC%
|
||||
) else (
|
||||
echo MiGu.Server 正常退出。
|
||||
)
|
||||
endlocal & exit /b %RUN_EC%
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1 @@
|
||||
import{aG as S,bo as z,aA as b,bB as a,u as B,b3 as p,aF as e,P as N,S as A,a4 as O,aC as T,bd as F,_ as M,ai as U,s as D,bm as f,aE as d,bE as I,ab as G,ad as L,bj as j,az as c,ba as P,bb as Y,Y as g,O as $,aa as q,I as H}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */const J={style:{display:"flex","align-items":"center",gap:"8px"}},pe=S({__name:"AnnotationView",setup(K){const s=z(),l=P({title:"",target:"",level:"info",content:""}),m=Y([{id:"N001",ts:"2026-05-19 10:25",author:"ops",target:"C05",level:"warn",title:"AGV-005 故障观察",content:"出库点附近频繁停顿,怀疑激光被遮挡"}]);function w(r){return r==="error"?"danger":r==="warn"?"warning":"info"}function v(){if(!l.title){g.warning("请填写标题");return}const r=`N${String(m.value.length+1).padStart(3,"0")}`;m.value.unshift({id:r,ts:new Date().toLocaleString("zh-CN"),author:s.user?.username??"mock",target:l.target,level:l.level,title:l.title,content:l.content}),g.success("已记录(Mock,未写入数据库)"),l.title="",l.content="",l.target=""}return(r,t)=>{const u=L,_=A,i=N,V=M,y=O,E=D,x=$,h=H,n=G,k=q,C=B;return p(),b(C,{shadow:"never"},{header:a(()=>[c("div",J,[t[7]||(t[7]=c("span",null,"运营备注(monitor.note.write · 写 platform.db.Annotations)",-1)),f(s).hasOp("monitor.note.write")?(p(),b(u,{key:0,size:"small",type:"success"},{default:a(()=>[...t[5]||(t[5]=[d("可写",-1)])]),_:1})):(p(),b(u,{key:1,size:"small",type:"danger"},{default:a(()=>[...t[6]||(t[6]=[d("只读",-1)])]),_:1}))])]),default:a(()=>[e(x,{inline:"",onSubmit:t[3]||(t[3]=I(()=>{},["prevent"]))},{default:a(()=>[e(i,{label:"标题"},{default:a(()=>[e(_,{modelValue:l.title,"onUpdate:modelValue":t[0]||(t[0]=o=>l.title=o),style:{width:"240px"}},null,8,["modelValue"])]),_:1}),e(i,{label:"关联目标"},{default:a(()=>[e(_,{modelValue:l.target,"onUpdate:modelValue":t[1]||(t[1]=o=>l.target=o),placeholder:"例如 C01 / M02 / S006",style:{width:"200px"}},null,8,["modelValue"])]),_:1}),e(i,{label:"层级"},{default:a(()=>[e(y,{modelValue:l.level,"onUpdate:modelValue":t[2]||(t[2]=o=>l.level=o),style:{width:"120px"}},{default:a(()=>[(p(),T(U,null,F(["info","warn","error"],o=>e(V,{key:o,label:o,value:o},null,8,["label","value"])),64))]),_:1},8,["modelValue"])]),_:1}),e(i,null,{default:a(()=>[e(E,{type:"primary",disabled:!f(s).hasOp("monitor.note.write"),onClick:v},{default:a(()=>[...t[8]||(t[8]=[d("提交",-1)])]),_:1},8,["disabled"])]),_:1})]),_:1}),e(_,{modelValue:l.content,"onUpdate:modelValue":t[4]||(t[4]=o=>l.content=o),type:"textarea",rows:4,placeholder:"描述备注内容...",disabled:!f(s).hasOp("monitor.note.write")},null,8,["modelValue","disabled"]),e(h),e(k,{data:m.value,size:"small","max-height":"400"},{default:a(()=>[e(n,{prop:"ts",label:"时间",width:"180"}),e(n,{prop:"author",label:"作者",width:"100"}),e(n,{prop:"target",label:"目标",width:"100"}),e(n,{prop:"level",label:"层级",width:"80"},{default:a(o=>[e(u,{size:"small",type:w(o.row.level)},{default:a(()=>[d(j(o.row.level),1)]),_:2},1032,["type"])]),_:1}),e(n,{prop:"title",label:"标题"}),e(n,{prop:"content",label:"内容"})]),_:1},8,["data"])]),_:1})}}});export{pe as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{aG as y,aA as n,bB as a,bm as E,D as T,b3 as o,aF as e,a9 as V,aa as k,ab as x,aC as d,bd as u,ad as C,aE as p,bj as s,ai as m,a8 as z,ac as B}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as D}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const O=y({__name:"AuthRoleView",setup(v){function w(c){switch(c){case"hidden":return"danger";case"readonly":return"warning";case"interactive":return"success";default:return"info"}}return(c,A)=>{const l=x,i=C,_=k,b=V,h=z,g=B;return o(),n(D,{section:"auth",title:"权限与角色管理",description:"角色、权限码、控件级 WidgetGrant(auth.user.write / auth.role.write)",defaults:E(T)},{default:a(({payload:f})=>[e(g,{"model-value":"roles"},{default:a(()=>[e(b,{name:"roles",label:"角色"},{default:a(()=>[e(_,{data:f.roles,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"名称",prop:"name",width:"120"}),e(l,{label:"Scope",prop:"scope",width:"120"}),e(l,{label:"权限码"},{default:a(r=>[(o(!0),d(m,null,u(r.row.permissions,t=>(o(),n(i,{key:t,size:"small",effect:"plain",style:{margin:"2px"}},{default:a(()=>[p(s(t),1)]),_:2},1024))),128))]),_:1}),e(l,{label:"WidgetGrant",width:"280"},{default:a(r=>[(o(!0),d(m,null,u(r.row.widgetGrants,t=>(o(),n(i,{key:t.widgetId,size:"small",type:w(t.visibility),effect:"plain",style:{margin:"2px"}},{default:a(()=>[p(s(t.widgetId)+"="+s(t.visibility),1)]),_:2},1032,["type"]))),128))]),_:1})]),_:1},8,["data"])]),_:2},1024),e(b,{name:"users",label:"用户"},{default:a(()=>[e(_,{data:f.users,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"用户名",prop:"username",width:"140"}),e(l,{label:"角色"},{default:a(r=>[(o(!0),d(m,null,u(r.row.roles,t=>(o(),n(i,{key:t,size:"small"},{default:a(()=>[p(s(t),1)]),_:2},1024))),128))]),_:1}),e(l,{label:"启用",width:"80"},{default:a(r=>[e(h,{modelValue:r.row.enabled,"onUpdate:modelValue":t=>r.row.enabled=t},null,8,["modelValue","onUpdate:modelValue"])]),_:1})]),_:1},8,["data"])]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{O as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.cse-form[data-v-3cdad944]{padding:8px 4px}.cse-color-code[data-v-3cdad944]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;color:var(--el-text-color-secondary);margin-left:8px}.cse-color-hint[data-v-3cdad944]{font-size:12px;color:var(--el-text-color-secondary);margin-left:8px}.cse-model-row[data-v-3cdad944]{display:flex;align-items:center;gap:8px;width:100%}.cse-model-hint[data-v-3cdad944]{margin:6px 0 0;font-size:12px;color:var(--el-text-color-secondary);line-height:1.55}.cse-root[data-v-3af8f925]{height:100%;display:flex;flex-direction:column;gap:12px;padding:12px;box-sizing:border-box;overflow:auto}.cse-card[data-v-3af8f925] .el-card__body{padding:12px 16px}.cse-card-header[data-v-3af8f925]{display:flex;align-items:center;gap:10px}.cse-card-title[data-v-3af8f925]{font-weight:600}.cse-card-spacer[data-v-3af8f925]{flex:1}.cse-desc[data-v-3af8f925]{margin:0 0 12px;color:var(--el-text-color-primary);font-size:13px;line-height:1.65;display:flex;flex-wrap:wrap;align-items:center;gap:6px}.cse-palette[data-v-3af8f925]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:10px}.cse-palette-item[data-v-3af8f925]{border:1px solid var(--el-border-color);background:var(--el-fill-color-light);border-radius:6px;padding:10px 12px;display:flex;flex-direction:column;gap:8px}.cse-palette-label[data-v-3af8f925]{display:flex;align-items:center;gap:6px}.cse-palette-key[data-v-3af8f925]{font-weight:600;color:var(--el-text-color-primary)}.cse-palette-pickers[data-v-3af8f925]{display:flex;align-items:center;gap:8px}.cse-color-code[data-v-3af8f925]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;color:var(--el-text-color-secondary)}.cse-types-card[data-v-3af8f925]{flex:1}.cse-row-title[data-v-3af8f925]{display:flex;align-items:center;gap:8px;width:100%}.cse-row-name[data-v-3af8f925]{font-weight:600}.cse-row-fqn[data-v-3af8f925]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;color:var(--el-text-color-secondary);flex:1;text-align:left;word-break:break-all}.cse-btn-text[data-v-3af8f925]{margin-left:4px}.car-page[data-v-c3733824]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box;display:flex;flex-direction:column}.car-tabs[data-v-c3733824]{flex:1;display:flex;flex-direction:column;overflow:hidden}.car-tabs[data-v-c3733824] .el-tabs__content{flex:1;overflow:hidden;padding:0}.car-tabs[data-v-c3733824] .el-tab-pane{height:100%;overflow:hidden}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as s,aA as c,bB as t,bm as d,a as u,b3 as f,aF as e,P as b,a8 as h,T as C,aa as g,ab as w,O as E}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const q=s({__name:"ChargePolicyView",setup(V){return(x,A)=>{const i=h,l=b,n=C,a=w,p=g,_=E;return f(),c(T,{section:"charge",title:"充电逻辑",description:"充电优先级、空闲充电、任务中断充电(ChargePolicy)",defaults:d(u)},{default:t(({payload:o,update:r})=>[e(_,{"label-width":"180px",model:o},{default:t(()=>[e(l,{label:"允许任务中断充电"},{default:t(()=>[e(i,{"model-value":o.allowMidTaskCharge,"onUpdate:modelValue":m=>r({...o,allowMidTaskCharge:!!m})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(l,{label:"空闲多少秒后充电"},{default:t(()=>[e(n,{"model-value":o.idleChargeAfterSec,min:0,max:86400,"onUpdate:modelValue":m=>r({...o,idleChargeAfterSec:m??0})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(l,{label:"充电优先级规则"},{default:t(()=>[e(p,{data:o.priority,size:"small"},{default:t(()=>[e(a,{label:"ID",prop:"id",width:"100"}),e(a,{label:"条件",prop:"condition"}),e(a,{label:"权重",prop:"weight",width:"100"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{q as default};
|
||||
@@ -0,0 +1 @@
|
||||
.cpb-header[data-v-83b90685]{display:flex;align-items:center;gap:8px}.cpb-title[data-v-83b90685]{font-weight:600;font-size:15px;color:#fff}.cpb-header .spacer[data-v-83b90685]{flex:1}.cpb-desc[data-v-83b90685]{color:#e8d7f5b3;font-size:12.5px;margin:0 0 14px;line-height:1.6}.cpb-body[data-v-83b90685]{margin-top:4px}.cpb-json[data-v-83b90685]{margin:0;padding:14px;font-size:12px;line-height:1.55;font-family:ui-monospace,Menlo,Consolas,monospace;background:#0f04208c;color:#d6c5ee;max-height:520px;overflow:auto}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{aG as _,aA as i,bB as e,bm as d,m as u,b3 as s,aF as a,ab as f,az as n,bj as r,aC as b,bd as g,ad as h,aE as w,ai as C,aa as E,al as T}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as y}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const B={class:"js-mini"},x={class:"js-mini"},S=_({__name:"CustomWidgetView",setup(V){return(k,D)=>{const t=f,m=h,p=E;return s(),i(y,{section:"widget",title:"自定义控件",description:"呼叫/展示界面可自由定义(platform-vue + rcsmonitor-vue 双渲染器)",defaults:d(u)},{default:e(({payload:c})=>[a(p,{data:c.items,size:"small",border:""},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"180"}),a(t,{label:"名称",prop:"name",width:"140"}),a(t,{label:"Schema",prop:"schemaJson"},{default:e(o=>[n("pre",B,r(o.row.schemaJson),1)]),_:1}),a(t,{label:"Layout",prop:"layoutJson"},{default:e(o=>[n("pre",x,r(o.row.layoutJson),1)]),_:1}),a(t,{label:"绑定 Scope"},{default:e(o=>[(s(!0),b(C,null,g(o.row.bindToScopes,l=>(s(),i(m,{key:l,size:"small",effect:"plain",style:{"margin-right":"4px"}},{default:e(()=>[w(r(l),1)]),_:2},1024))),128))]),_:1})]),_:1},8,["data"])]),_:1},8,["defaults"])}}}),H=T(S,[["__scopeId","data-v-96b90481"]]);export{H as default};
|
||||
@@ -0,0 +1 @@
|
||||
.js-mini[data-v-96b90481]{margin:0;font-size:11px;background:#f5f7fa;padding:4px 6px;border-radius:4px;max-height:60px;overflow:auto}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{aG as b,aA as u,bB as a,bm as f,b as h,b3 as w,aF as e,a9 as v,aa as E,ab as D,a8 as C,F as P,G as T,aE as i,bj as r,ac as V}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as g}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const Q=b({__name:"DeviceHubView",setup(x){return(B,I)=>{const l=D,s=E,n=v,d=C,o=T,c=P,m=V;return w(),u(g,{section:"device",title:"第三方设备统一接入",description:"电梯/卷帘门/安全门/充电桩/AP/交换机/摄像头/读码器/PLC(DeviceManagementConfig)",defaults:f(h)},{default:a(({payload:t})=>[e(m,{"model-value":"drivers"},{default:a(()=>[e(n,{name:"drivers",label:"驱动绑定"},{default:a(()=>[e(s,{data:t.drivers,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"设备类型",prop:"deviceType",width:"120"}),e(l,{label:"驱动名",prop:"driverName"}),e(l,{label:"版本",prop:"version",width:"100"})]),_:1},8,["data"])]),_:2},1024),e(n,{name:"devices",label:"设备实例"},{default:a(()=>[e(s,{data:t.devices,size:"small",border:""},{default:a(()=>[e(l,{label:"ID",prop:"id",width:"120"}),e(l,{label:"名称",prop:"name",width:"140"}),e(l,{label:"类型",prop:"deviceType",width:"100"}),e(l,{label:"协议",prop:"protocol",width:"120"}),e(l,{label:"地址",prop:"address"}),e(l,{label:"启用",width:"80"},{default:a(p=>[e(d,{modelValue:p.row.enabled,"onUpdate:modelValue":_=>p.row.enabled=_},null,8,["modelValue","onUpdate:modelValue"])]),_:1})]),_:1},8,["data"])]),_:2},1024),e(n,{name:"policy",label:"健康 / 告警"},{default:a(()=>[e(c,{column:2,border:""},{default:a(()=>[e(o,{label:"心跳周期 (s)"},{default:a(()=>[i(r(t.healthPolicy.heartbeatSec),1)]),_:2},1024),e(o,{label:"离线判定 (s)"},{default:a(()=>[i(r(t.healthPolicy.offlineSec),1)]),_:2},1024),e(o,{label:"告警启用"},{default:a(()=>[i(r(t.alarmPolicy.enabled?"是":"否"),1)]),_:2},1024),e(o,{label:"规则数"},{default:a(()=>[i(r(t.alarmPolicy.rules.length),1)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{Q as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as U,aA as x,bB as a,bm as c,d as g,b3 as b,aF as t,aC as h,bd as T,a9 as S,aa as B,ab as I,S as $,a8 as v,s as D,aE as f,b6 as N,bj as z,ai as A,ac as F}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as L}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const te=U({__name:"ExternalIntegrationView",setup(R){function w(r,s,l){const o=`${l}-${Date.now()}`,u={...r,[l]:[...r[l],{id:o,name:"新端点",url:"http://",enabled:!1}]};s(u)}function V(r,s,l,o){const u={...r,[l]:r[l].filter((i,d)=>d!==o)};s(u)}return(r,s)=>{const l=$,o=I,u=v,i=D,d=B,E=S,C=F;return b(),x(L,{section:"integrations",title:"外部系统对接",description:"MES / WMS / RCS 等标准接口配置(ExternalIntegrations)",defaults:c(g)},{default:a(({payload:p,update:_})=>[t(C,{"model-value":"mes"},{default:a(()=>[(b(),h(A,null,T(["mes","wms","rcs"],m=>t(E,{key:m,name:m,label:m.toUpperCase()},{default:a(()=>[t(d,{data:p[m],size:"small",border:""},{default:a(()=>[t(o,{label:"ID",width:"120"},{default:a(e=>[t(l,{modelValue:e.row.id,"onUpdate:modelValue":n=>e.row.id=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"名称","min-width":"160"},{default:a(e=>[t(l,{modelValue:e.row.name,"onUpdate:modelValue":n=>e.row.name=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"URL","min-width":"260"},{default:a(e=>[t(l,{modelValue:e.row.url,"onUpdate:modelValue":n=>e.row.url=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"启用",width:"80"},{default:a(e=>[t(u,{modelValue:e.row.enabled,"onUpdate:modelValue":n=>e.row.enabled=n},null,8,["modelValue","onUpdate:modelValue"])]),_:1}),t(o,{label:"操作",width:"80"},{default:a(e=>[t(i,{text:"",type:"danger",size:"small",onClick:n=>V(p,_,m,e.$index)},{default:a(()=>[...s[0]||(s[0]=[f("删除",-1)])]),_:1},8,["onClick"])]),_:2},1024)]),_:2},1032,["data"]),t(i,{size:"small",icon:c(N),style:{"margin-top":"8px"},onClick:e=>w(p,_,m)},{default:a(()=>[f("新增 "+z(m.toUpperCase())+" 端点",1)]),_:2},1032,["icon","onClick"])]),_:2},1032,["name","label"])),64))]),_:2},1024)]),_:1},8,["defaults"])}}});export{te as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as f,aA as m,bB as t,bm as g,c as h,b3 as _,aF as e,a9 as T,aa as E,ab as w,aC as k,bd as F,ad as C,aE as l,bj as o,ai as D,F as x,G as B,ac as L}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as O}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const X=f({__name:"FleetLifecycleView",setup(A){return(z,I)=>{const s=w,p=C,d=E,n=T,r=B,i=x,b=L;return _(),m(O,{section:"fleet",title:"车队全生命周期",description:"分区域/跨楼层/多车协作;OTA、批量操作、网络诊断(FleetLifecycleConfig)",defaults:g(h)},{default:t(({payload:a})=>[e(b,{"model-value":"groups"},{default:t(()=>[e(n,{name:"groups",label:"车队分组"},{default:t(()=>[e(d,{data:a.groups,size:"small",border:""},{default:t(()=>[e(s,{label:"ID",prop:"id",width:"100"}),e(s,{label:"名称",prop:"name",width:"140"}),e(s,{label:"楼层",prop:"floor",width:"80"}),e(s,{label:"区域",prop:"region",width:"80"}),e(s,{label:"车辆"},{default:t(u=>[(_(!0),k(D,null,F(u.row.carIds,c=>(_(),m(p,{key:c,size:"small",style:{margin:"2px"}},{default:t(()=>[l(o(c),1)]),_:2},1024))),128))]),_:1})]),_:1},8,["data"])]),_:2},1024),e(n,{name:"ota",label:"OTA 升级"},{default:t(()=>[e(i,{column:2,border:""},{default:t(()=>[e(r,{label:"启用"},{default:t(()=>[l(o(a.ota.enabled?"是":"否"),1)]),_:2},1024),e(r,{label:"批次大小"},{default:t(()=>[l(o(a.ota.batchSize),1)]),_:2},1024),e(r,{label:"失败回滚"},{default:t(()=>[l(o(a.ota.rollbackOnFail?"是":"否"),1)]),_:2},1024)]),_:2},1024)]),_:2},1024),e(n,{name:"batch",label:"批量操作"},{default:t(()=>[e(i,{column:2,border:""},{default:t(()=>[e(r,{label:"需确认"},{default:t(()=>[l(o(a.batchOps.confirmationRequired?"是":"否"),1)]),_:2},1024),e(r,{label:"最大批量"},{default:t(()=>[l(o(a.batchOps.maxBatch),1)]),_:2},1024)]),_:2},1024)]),_:2},1024),e(n,{name:"diag",label:"网络诊断"},{default:t(()=>[e(i,{column:2,border:""},{default:t(()=>[e(r,{label:"RTT 阈值 (ms)"},{default:t(()=>[l(o(a.networkDiag.rttThresholdMs),1)]),_:2},1024),e(r,{label:"丢包阈值"},{default:t(()=>[l(o(a.networkDiag.packetLossThreshold),1)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{X as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as s,aA as m,bB as t,bm as c,e as d,b3 as _,aF as e,a9 as b,aa as u,ab as f,$ as w,ac as h}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import{C as g}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const V=s({__name:"LocationView",setup(T){return(C,E)=>{const a=f,i=w,o=u,l=b,n=h;return _(),m(g,{section:"location",title:"库位管理",description:"出入库、库存、库位可视化(LocationManagement)",defaults:c(d)},{default:t(({payload:p})=>[e(n,{"model-value":"locs"},{default:t(()=>[e(l,{name:"locs",label:"库位"},{default:t(()=>[e(o,{data:p.locations,size:"small",border:""},{default:t(()=>[e(a,{label:"ID",prop:"id",width:"100"}),e(a,{label:"编码",prop:"code",width:"120"}),e(a,{label:"名称",prop:"name"}),e(a,{label:"站点",prop:"siteId",width:"100"}),e(a,{label:"容量",prop:"capacity",width:"100"}),e(a,{label:"占用"},{default:t(r=>[e(i,{percentage:Math.round(r.row.occupied/r.row.capacity*100),"stroke-width":10},null,8,["percentage"])]),_:1})]),_:1},8,["data"])]),_:2},1024),e(l,{name:"rules",label:"库存规则"},{default:t(()=>[e(o,{data:p.inventoryRules,size:"small",border:""},{default:t(()=>[e(a,{label:"ID",prop:"id",width:"100"}),e(a,{label:"物料类型",prop:"itemType"}),e(a,{label:"下限",prop:"minQty",width:"100"}),e(a,{label:"上限",prop:"maxQty",width:"100"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{V as default};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.mmcg[data-v-c29d34fc]{display:flex;flex-direction:column;gap:8px;padding:12px 14px;background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:8px}.mmcg-head[data-v-c29d34fc]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.mmcg-title[data-v-c29d34fc]{font-weight:600;font-size:13px;color:#fff}.mmcg-head .spacer[data-v-c29d34fc]{flex:1}.mmcg-empty[data-v-c29d34fc]{margin:4px 0 0;font-size:12px;font-style:italic}.muted[data-v-c29d34fc]{color:#ffffff9e}.mmcg-grid[data-v-c29d34fc]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 12px;align-items:center}.mmcg-grid[data-v-c29d34fc] .el-checkbox{margin-right:0;height:28px}.mmcg-grid[data-v-c29d34fc] .el-checkbox__label{color:#ffffffeb;font-size:12.5px;font-family:ui-monospace,Menlo,Consolas,monospace}.mmcg-orphans[data-v-c29d34fc]{margin-top:4px}.orphan-list[data-v-c29d34fc]{display:flex;align-items:center;flex-wrap:wrap;gap:6px}.mmc-card[data-v-13970b71]{margin:0}.mmc-header[data-v-13970b71]{display:flex;align-items:center;gap:8px}.mmc-title[data-v-13970b71]{font-weight:600;font-size:15px;color:#fff}.mmc-header .spacer[data-v-13970b71]{flex:1}.mmc-desc[data-v-13970b71]{color:#e8d7f5c7;font-size:12.5px;margin:0 0 12px;line-height:1.7}.mmc-desc code[data-v-13970b71]{background:#ffffff14;padding:1px 5px;border-radius:4px;font-family:ui-monospace,Menlo,Consolas,monospace}.mmc-tabs[data-v-13970b71] .el-tabs__item{color:#ffffffd1!important}.mmc-tabs[data-v-13970b71] .el-tabs__item.is-active{color:#fff!important}.mmc-tab-body[data-v-13970b71]{display:flex;flex-direction:column;gap:18px;padding-top:6px}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.mission-page[data-v-40f885f1]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box}
|
||||
@@ -0,0 +1 @@
|
||||
import{R as o}from"./ReflectionManagerPanel-BP1OYef-.js";import{aG as i,aC as t,aF as r,b3 as p,al as e}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const m={class:"mission-page"},s=i({__name:"MissionEditorView",setup(a){return(n,c)=>(p(),t("div",m,[r(o,{kind:"process","kind-label":"任务",title:"任务编排(Mission / 进程,含插件 MissionType 实例化)","empty-text":"当前没有任务;点击右上角「新建任务」从已加载的 MissionType 中选一个实例化。"})]))}}),q=e(s,[["__scopeId","data-v-40f885f1"]]);export{q as default};
|
||||
@@ -0,0 +1 @@
|
||||
.muted[data-v-d6b33cf6]{color:#e8d7f58c;font-size:12px}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as k,b1 as z,aC as M,aF as t,bB as e,bb as p,a2 as T,b3 as V,x as B,a6 as D,az as d,bj as c,u as N,aa as S,ab as I,ad as P,aE as w,$ as j,av as _,al as A}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{l as F,a as G}from"./projection-CizY2UtH.js";import{l as R}from"./ops-CZkWvMAR.js";import"./reflection-Dr3WM5Rz.js";const $={class:"muted"},q=k({__name:"MonitorDashboardView",setup(H){const n=p([]),m=p([]),i=p([]),b=_(()=>n.value.filter(l=>l.state!=="offline").length),C=_(()=>m.value.filter(l=>l.status==="running").length),x=_(()=>n.value.filter(l=>l.state==="fault").length);function E(l){switch(l){case"running":return"success";case"idle":return"info";case"charging":return"warning";case"paused":return"warning";case"fault":return"danger";case"offline":return"info";default:return"info"}}return z(async()=>{[n.value,m.value,i.value]=await Promise.all([F(),G(),R()])}),(l,r)=>{const u=D,o=B,f=T,a=I,g=P,y=j,v=S,h=N;return V(),M("div",null,[t(f,{gutter:14,class:"kpi-row"},{default:e(()=>[t(o,{span:6},{default:e(()=>[t(u,{title:"车辆在线",value:b.value},{suffix:e(()=>[d("span",$,"/ "+c(n.value.length),1)]),_:1},8,["value"])]),_:1}),t(o,{span:6},{default:e(()=>[t(u,{title:"任务进行中",value:C.value},null,8,["value"])]),_:1}),t(o,{span:6},{default:e(()=>[t(u,{title:"故障车辆",value:x.value},null,8,["value"])]),_:1}),t(o,{span:6},{default:e(()=>[t(u,{title:"今日运维动作",value:i.value.length},null,8,["value"])]),_:1})]),_:1}),t(f,{gutter:14,style:{"margin-top":"14px"}},{default:e(()=>[t(o,{span:14},{default:e(()=>[t(h,{shadow:"never"},{header:e(()=>[...r[0]||(r[0]=[d("span",null,"实时车辆",-1)])]),default:e(()=>[t(v,{data:n.value,size:"small","max-height":"360",stripe:""},{default:e(()=>[t(a,{prop:"name",label:"名称",width:"100"}),t(a,{prop:"state",label:"状态",width:"90"},{default:e(s=>[t(g,{size:"small",type:E(s.row.state)},{default:e(()=>[w(c(s.row.state),1)]),_:2},1032,["type"])]),_:1}),t(a,{prop:"group",label:"分组",width:"100"}),t(a,{label:"电量",width:"160"},{default:e(s=>[t(y,{percentage:Math.round(s.row.batterySoc*100),"stroke-width":8},null,8,["percentage"])]),_:1}),t(a,{prop:"missionId",label:"当前任务"})]),_:1},8,["data"])]),_:1})]),_:1}),t(o,{span:10},{default:e(()=>[t(h,{shadow:"never"},{header:e(()=>[...r[1]||(r[1]=[d("span",null,"最近运维动作",-1)])]),default:e(()=>[t(v,{data:i.value,size:"small","max-height":"360",stripe:""},{default:e(()=>[t(a,{prop:"ts",label:"时间",width:"180"}),t(a,{prop:"opCode",label:"动作"}),t(a,{prop:"target",label:"目标",width:"100"}),t(a,{prop:"result",label:"结果",width:"80"},{default:e(s=>[t(g,{size:"small",type:s.row.result==="ok"?"success":"danger"},{default:e(()=>[w(c(s.row.result),1)]),_:2},1032,["type"])]),_:1})]),_:1},8,["data"])]),_:1})]),_:1})]),_:1})])}}}),ot=A(q,[["__scopeId","data-v-d6b33cf6"]]);export{ot as default};
|
||||
@@ -0,0 +1,2 @@
|
||||
import{aG as A,bo as G,b1 as L,aC as d,aF as a,bB as o,bb as r,E as j,a2 as H,b3 as i,aE as u,bj as _,bm as c,x as Y,az as f,u as Z,a1 as q,a0 as J,a4 as K,ai as b,bd as I,aA as S,F as Q,G as X,ag as ee,R as te,aR as ae,aB as oe,av as le,_ as ne,s as se,Y as g,Z as re,al as ie}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{W as ue}from"./Workspace3D-D7MYl2zT.js";import{W as ce}from"./WorkspaceCanvasToolbar-1mCqpdq_.js";import{O as pe}from"./ops-BCeFEEjY.js";import{l as me,a as de}from"./projection-CizY2UtH.js";import{e as _e}from"./ops-CZkWvMAR.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";const fe={class:"monitor-map-page"},ve={class:"canvas-with-toolbar"},be={class:"ops-grid"},ge=A({__name:"MonitorMapView",setup(ye){const y=G(),E="localhost:8223",p=r("car"),s=r(""),w=r([]),k=r([]),m=r(null),v=r([]),$=le(()=>p.value==="car"?w.value:k.value);function M(t){return t.target===p.value||t.target==="note"}async function B(t){if(!s.value&&t.target!=="note"){g.warning("请先选择目标");return}if(t.needConfirm)try{await re.confirm(`确认执行 [${t.label}]?
|
||||
目标:${s.value}`,"二次确认",{type:"warning"})}catch{return}try{const e=await _e({opCode:t.code,targetId:s.value,reason:""});g.success(`已执行 ${t.label},auditId=${e.auditId}`)}catch(e){g.error(`执行失败:${e instanceof Error?e.message:String(e)}`)}}function O(t){m.value=t}function R(t){v.value=t,t.length===1&&t[0].startsWith("UICar-")&&(p.value="car",s.value=t[0].replace("UICar-",""))}return L(async()=>{const t=await me();w.value=t.map(n=>({label:`${n.name} (${n.id})`,value:n.id}));const e=await de();k.value=e.map(n=>({label:`${n.name} (${n.id})`,value:n.id}))}),(t,e)=>{const n=j,x=Y,C=J,T=q,W=ne,D=K,h=X,F=Q,V=Z,z=te,N=ee,P=se,U=H;return i(),d("div",fe,[a(n,{type:"info",closable:!1,"show-icon":"",style:{"margin-bottom":"12px"}},{default:o(()=>[u(" RCSMonitor 只读视图:iframe 嵌入 SimpleLite webVRender("+_(c(E))+"),右侧可执行白名单运维动作。 ",1)]),_:1}),a(U,{gutter:12,class:"main-row"},{default:o(()=>[a(x,{span:17},{default:o(()=>[f("div",ve,[a(ue,{host:c(E),scope:"RCSMonitor",token:c(y).token??"","read-only":!0,"embed-ui":!0,onPick:O,onSelect:R},null,8,["host","token"]),a(ce)])]),_:1}),a(x,{span:7},{default:o(()=>[a(V,{shadow:"never"},{header:o(()=>[...e[2]||(e[2]=[f("span",null,"选中目标",-1)])]),default:o(()=>[a(T,{modelValue:p.value,"onUpdate:modelValue":e[0]||(e[0]=l=>p.value=l),size:"small"},{default:o(()=>[a(C,{label:"car"},{default:o(()=>[...e[3]||(e[3]=[u("车辆",-1)])]),_:1}),a(C,{label:"task"},{default:o(()=>[...e[4]||(e[4]=[u("任务",-1)])]),_:1})]),_:1},8,["modelValue"]),a(D,{modelValue:s.value,"onUpdate:modelValue":e[1]||(e[1]=l=>s.value=l),placeholder:"选择目标 ID",filterable:"",style:{width:"100%","margin-top":"8px"}},{default:o(()=>[(i(!0),d(b,null,I($.value,l=>(i(),S(W,{key:l.value,label:l.label,value:l.value},null,8,["label","value"]))),128))]),_:1},8,["modelValue"]),a(F,{column:1,border:"",size:"small",style:{"margin-top":"8px"}},{default:o(()=>[a(h,{label:"最近 Pick"},{default:o(()=>[u(_(m.value?`(${m.value.x.toFixed(0)}, ${m.value.y.toFixed(0)})`:"—"),1)]),_:1}),a(h,{label:"最近 Select"},{default:o(()=>[u(_(v.value.length?v.value.join(", "):"—"),1)]),_:1})]),_:1})]),_:1}),a(V,{shadow:"never",style:{"margin-top":"12px"}},{header:o(()=>[...e[5]||(e[5]=[f("span",null,"运维白名单(按权限隐藏)",-1)])]),default:o(()=>[f("div",be,[(i(!0),d(b,null,I(c(pe),l=>(i(),d(b,{key:l.code},[c(y).hasOp(l.code)&&M(l)?(i(),S(P,{key:0,type:l.needConfirm?"warning":"primary",size:"small",plain:"",onClick:Ee=>B(l)},{default:o(()=>[u(_(l.label)+" ",1),a(N,{content:l.description},{default:o(()=>[a(z,{style:{"margin-left":"4px"}},{default:o(()=>[a(c(ae))]),_:1})]),_:1},8,["content"])]),_:2},1032,["type","onClick"])):oe("",!0)],64))),128))])]),_:1})]),_:1})]),_:1})])}}}),He=ie(ge,[["__scopeId","data-v-96c491ac"]]);export{He as default};
|
||||
@@ -0,0 +1 @@
|
||||
.monitor-map-page[data-v-96c491ac]{display:flex;flex-direction:column;height:calc(100vh - 124px)}.main-row[data-v-96c491ac]{flex:1;min-height:0}.main-row>.el-col[data-v-96c491ac]{display:flex;flex-direction:column}.main-row>.el-col[data-v-96c491ac]:first-child{min-height:0}.ops-grid[data-v-96c491ac]{display:grid;grid-template-columns:1fr 1fr;gap:8px}.canvas-with-toolbar[data-v-96c491ac]{flex:1;min-height:0;display:flex;flex-direction:column}.canvas-with-toolbar[data-v-96c491ac] .workspace-3d-wrap{flex:1;min-height:0}
|
||||
@@ -0,0 +1 @@
|
||||
.el-timeline{--el-timeline-node-size-normal:12px;--el-timeline-node-size-large:14px;--el-timeline-node-color:var(--el-border-color-light);font-size:var(--el-font-size-base);margin:0;list-style:none}.el-timeline .el-timeline-item:last-child .el-timeline-item__tail{display:none}.el-timeline .el-timeline-item__center{align-items:center;display:flex}.el-timeline .el-timeline-item__center .el-timeline-item__wrapper{width:100%}.el-timeline .el-timeline-item__center .el-timeline-item__tail{top:0}.el-timeline .el-timeline-item__center:first-child .el-timeline-item__tail{height:calc(50% + 10px);top:calc(50% - 10px)}.el-timeline .el-timeline-item__center:last-child .el-timeline-item__tail{height:calc(50% - 10px);display:block}.el-timeline.is-start{padding-left:40px;padding-right:0}.el-timeline.is-end{padding-left:0;padding-right:40px}.el-timeline.is-alternate{padding-left:20px;padding-right:20px}.el-timeline.is-alternate .el-timeline-item:nth-child(odd) .el-timeline-item__wrapper{width:calc(50% - 28px);left:calc(50% - var(--el-timeline-node-size-large) / 2);padding-left:28px}.el-timeline.is-alternate .el-timeline-item:nth-child(2n) .el-timeline-item__wrapper{width:calc(50% - 28px + var(--el-timeline-node-size-large) / 2);text-align:right;padding-right:28px}.el-timeline.is-alternate-reverse{padding-left:20px;padding-right:20px}.el-timeline.is-alternate-reverse .el-timeline-item:nth-child(odd) .el-timeline-item__wrapper{width:calc(50% - 28px + var(--el-timeline-node-size-large) / 2);text-align:right;padding-right:28px}.el-timeline.is-alternate-reverse .el-timeline-item:nth-child(2n) .el-timeline-item__wrapper{width:calc(50% - 28px);left:calc(50% - var(--el-timeline-node-size-large) / 2);padding-left:28px}.el-timeline-item{padding-bottom:20px;position:relative}.el-timeline-item__wrapper{box-sizing:content-box;position:relative;top:-3px}.el-timeline-item__tail{border-left:2px solid var(--el-timeline-node-color);height:100%;position:absolute}.el-timeline-item .el-timeline-item__icon{color:var(--el-color-white);font-size:var(--el-font-size-small)}.el-timeline-item__node{background-color:var(--el-timeline-node-color);border-color:var(--el-timeline-node-color);box-sizing:border-box;border-radius:50%;justify-content:center;align-items:center;display:flex;position:absolute}.el-timeline-item__node--normal{width:var(--el-timeline-node-size-normal);height:var(--el-timeline-node-size-normal)}.el-timeline-item__node--large{width:var(--el-timeline-node-size-large);height:var(--el-timeline-node-size-large)}.el-timeline-item__node.is-hollow{background:var(--el-color-white);border-style:solid;border-width:2px}.el-timeline-item__node--primary{background-color:var(--el-color-primary);border-color:var(--el-color-primary)}.el-timeline-item__node--success{background-color:var(--el-color-success);border-color:var(--el-color-success)}.el-timeline-item__node--warning{background-color:var(--el-color-warning);border-color:var(--el-color-warning)}.el-timeline-item__node--danger{background-color:var(--el-color-danger);border-color:var(--el-color-danger)}.el-timeline-item__node--info{background-color:var(--el-color-info);border-color:var(--el-color-info)}.el-timeline-item__dot{justify-content:center;align-items:center;display:flex;position:absolute}.el-timeline-item__content{color:var(--el-text-color-primary)}.el-timeline-item__timestamp{color:var(--el-text-color-secondary);line-height:1;font-size:var(--el-font-size-small)}.el-timeline-item__timestamp.is-top{margin-bottom:8px;padding-top:4px}.el-timeline-item__timestamp.is-bottom{margin-top:8px}.el-timeline-item.is-start .el-timeline-item__wrapper{padding-left:28px}.el-timeline-item.is-start .el-timeline-item__tail{left:4px}.el-timeline-item.is-start .el-timeline-item__node--normal{left:-1px}.el-timeline-item.is-start .el-timeline-item__node--large{left:-2px}.el-timeline-item.is-end .el-timeline-item__wrapper{text-align:right;padding-right:28px}.el-timeline-item.is-end .el-timeline-item__tail{right:4px}.el-timeline-item.is-end .el-timeline-item__node--normal{right:-1px}.el-timeline-item.is-end .el-timeline-item__node--large{right:-2px}.el-timeline-item.is-alternate .el-timeline-item__tail,.el-timeline-item.is-alternate .el-timeline-item__node,.el-timeline-item.is-alternate-reverse .el-timeline-item__tail,.el-timeline-item.is-alternate-reverse .el-timeline-item__node{left:50%;transform:translate(-50%)}
|
||||
@@ -0,0 +1,2 @@
|
||||
import{aG as O,bo as T,b1 as V,aA as m,bB as a,bb as B,b3 as d,aF as o,aa as S,bm as _,ab as A,az as p,bj as r,ad as M,aE as i,S as P,s as $,ae as D,aC as N,bd as F,af as G,ai as L,ba as U,av as j,Y as f,Z as H,u as R}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{P as W}from"./PermissionGuard-BxagL6-4.js";import{O as b}from"./ops-BCeFEEjY.js";import{l as C,e as Y}from"./ops-CZkWvMAR.js";const Z={style:{display:"flex","align-items":"center",gap:"8px"}},pe=O({__name:"OpsActionPanelView",setup(q){const g=T(),u=U({}),c=B([]),E=j(()=>b.filter(n=>g.hasOp(n.code)));async function x(n){const e=u[n.code];if(!e&&n.target!=="note"){f.warning("请填写目标 ID");return}if(n.needConfirm)try{await H.confirm(`确认执行 [${n.label}]?
|
||||
目标:${e}`,"二次确认",{type:"warning"})}catch{return}try{const l=await Y({opCode:n.code,targetId:e});f.success(`成功,auditId=${l.auditId}`),c.value=await C()}catch(l){f.error(`失败:${l instanceof Error?l.message:String(l)}`)}}return V(async()=>{c.value=await C()}),(n,e)=>{const l=M,s=A,h=P,k=$,v=S,w=R,I=G,z=D;return d(),m(W,{"widget-id":"OpsActionPanel"},{default:a(()=>[o(w,{shadow:"never"},{header:a(()=>[p("div",Z,[e[1]||(e[1]=p("span",null,"运维操作(架构 §5.1 白名单)",-1)),o(l,{size:"small",type:"info"},{default:a(()=>[...e[0]||(e[0]=[i("scope=RCSMonitor",-1)])]),_:1}),o(l,{size:"small",type:"success"},{default:a(()=>[i(r(E.value.length)+" / "+r(_(b).length)+" 可用",1)]),_:1})])]),default:a(()=>[o(v,{data:_(b),size:"small",border:""},{default:a(()=>[o(s,{prop:"code",label:"权限码",width:"200"},{default:a(t=>[p("code",null,r(t.row.code),1)]),_:1}),o(s,{prop:"label",label:"操作",width:"140"}),o(s,{prop:"target",label:"目标",width:"80"}),o(s,{prop:"needConfirm",label:"二次确认",width:"100"},{default:a(t=>[t.row.needConfirm?(d(),m(l,{key:0,size:"small",type:"warning"},{default:a(()=>[...e[2]||(e[2]=[i("是",-1)])]),_:1})):(d(),m(l,{key:1,size:"small",effect:"plain"},{default:a(()=>[...e[3]||(e[3]=[i("否",-1)])]),_:1}))]),_:1}),o(s,{prop:"description",label:"说明"}),o(s,{label:"操作",width:"200"},{default:a(t=>[o(h,{modelValue:u[t.row.code],"onUpdate:modelValue":y=>u[t.row.code]=y,placeholder:"目标 ID",size:"small",style:{width:"100px","margin-right":"6px"}},null,8,["modelValue","onUpdate:modelValue"]),o(k,{size:"small",type:t.row.needConfirm?"warning":"primary",disabled:!_(g).hasOp(t.row.code),onClick:y=>x(t.row)},{default:a(()=>[...e[4]||(e[4]=[i("执行",-1)])]),_:1},8,["type","disabled","onClick"])]),_:1})]),_:1},8,["data"])]),_:1}),o(w,{shadow:"never",style:{"margin-top":"12px"}},{header:a(()=>[...e[5]||(e[5]=[p("span",null,"本地操作记录(占位 Mock)",-1)])]),default:a(()=>[o(z,null,{default:a(()=>[(d(!0),N(L,null,F(c.value,t=>(d(),m(I,{key:t.id,timestamp:t.ts,type:t.result==="ok"?"success":"danger"},{default:a(()=>[p("strong",null,r(t.opCode),1),i(" → "+r(t.target)+"("+r(t.user)+") ",1)]),_:2},1032,["timestamp","type"]))),128))]),_:1})]),_:1})]),_:1})}}});export{pe as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.cfg-check-grid[data-v-e66d9ef7]{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:4px 10px}.type-actions[data-v-e66d9ef7]{width:100%}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as i,bo as n,be as o,aC as l,az as u,aB as c,av as m,b3 as p,al as v}from"./index-DrvLDslf.js";const f=["title"],g=i({__name:"PermissionGuard",props:{widgetId:{}},setup(e){const d=e,r=n(),t=m(()=>r.widgetOf(d.widgetId));return(s,a)=>t.value==="interactive"?o(s.$slots,"default",{key:0},void 0,!0):t.value==="readonly"?(p(),l("div",{key:1,class:"pg-readonly",title:`${e.widgetId} 当前为只读`},[o(s.$slots,"default",{},void 0,!0),a[0]||(a[0]=u("div",{class:"pg-mask"},null,-1))],8,f)):c("",!0)}}),k=v(g,[["__scopeId","data-v-3220f2f7"]]);export{k as P};
|
||||
@@ -0,0 +1 @@
|
||||
.pg-readonly[data-v-3220f2f7]{position:relative}.pg-mask[data-v-3220f2f7]{position:absolute;inset:0;background:#ffffff4d;pointer-events:all;cursor:not-allowed}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as c,aA as y,bB as a,u as w,b3 as E,aF as e,x as V,S as k,C as x,s as B,aE as n,bm as g,bx as z,ab as N,az as P,bj as v,ba as C,bb as S,a2 as A,aa as D,I}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */const K=30,X=c({__name:"PlaybackView",setup(T){const s=C({kw:"",range:null}),i=S([{id:"SNAP-001",ts:"2026-05-19 10:21:33",type:"调度异常",summary:"AGV-005 上线超时 → 故障",size:"128 KB"},{id:"SNAP-002",ts:"2026-05-19 14:05:17",type:"路口死锁",summary:"路口-N 等待链 3 节点 30s",size:"64 KB"},{id:"SNAP-003",ts:"2026-05-20 09:11:02",type:"手动快照",summary:"用户 admin 触发 SnapshotExport",size:"420 KB"}]);return(G,t)=>{const d=k,r=V,m=x,o=B,u=A,_=I,l=N,b=D,f=w;return E(),y(f,{shadow:"never"},{header:a(()=>[P("span",null,"调度回放(PlaybackPolicy.retentionDays="+v(K)+" 天)")]),default:a(()=>[e(u,{gutter:12},{default:a(()=>[e(r,{span:6},{default:a(()=>[e(d,{modelValue:s.kw,"onUpdate:modelValue":t[0]||(t[0]=p=>s.kw=p),placeholder:"按任务 / 车辆检索",clearable:""},null,8,["modelValue"])]),_:1}),e(r,{span:8},{default:a(()=>[e(m,{modelValue:s.range,"onUpdate:modelValue":t[1]||(t[1]=p=>s.range=p),type:"datetimerange","range-separator":"→","start-placeholder":"开始","end-placeholder":"结束",style:{width:"100%"}},null,8,["modelValue"])]),_:1}),e(r,{span:10},{default:a(()=>[e(o,{type:"primary"},{default:a(()=>[...t[2]||(t[2]=[n("检索快照",-1)])]),_:1}),e(o,null,{default:a(()=>[...t[3]||(t[3]=[n("下载日志",-1)])]),_:1}),e(o,{icon:g(z),plain:""},{default:a(()=>[...t[4]||(t[4]=[n("回放选中",-1)])]),_:1},8,["icon"])]),_:1})]),_:1}),e(_),e(b,{data:i.value,stripe:""},{default:a(()=>[e(l,{prop:"id",label:"ID",width:"100"}),e(l,{prop:"ts",label:"时间",width:"180"}),e(l,{prop:"type",label:"类型",width:"140"}),e(l,{prop:"summary",label:"概要"}),e(l,{prop:"size",label:"大小",width:"100"}),e(l,{label:"操作",width:"160"},{default:a(()=>[e(o,{text:"",size:"small"},{default:a(()=>[...t[5]||(t[5]=[n("回放",-1)])]),_:1}),e(o,{text:"",size:"small"},{default:a(()=>[...t[6]||(t[6]=[n("下载",-1)])]),_:1})]),_:1})]),_:1},8,["data"])]),_:1})}}});export{X as default};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{R as o}from"./ReflectionManagerPanel-BP1OYef-.js";import{aG as t,aC as r,aF as e,b3 as i,al as p}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const m={class:"process-page"},s=t({__name:"ProcessPanelView",setup(a){return(n,c)=>(i(),r("div",m,[e(o,{kind:"process","kind-label":"进程",title:"进程管理(Mission / Process)","empty-text":"当前没有进程;点击右上角「新建进程」即可基于已加载的 MissionType 实例化一个。"})]))}}),q=p(s,[["__scopeId","data-v-6a85813e"]]);export{q as default};
|
||||
@@ -0,0 +1 @@
|
||||
.process-page[data-v-6a85813e]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.fme-root[data-v-708f3ec1]{height:100%;display:flex;flex-direction:column;gap:12px;padding:12px;box-sizing:border-box}.fme-header[data-v-708f3ec1]{display:flex;flex-direction:column;gap:10px}.fme-desc[data-v-708f3ec1]{margin:0;color:var(--el-text-color-primary);font-size:13px;line-height:1.65;background:var(--el-fill-color-light);padding:10px 12px;border-left:3px solid var(--el-color-primary);border-radius:4px}.fme-paths code.fme-path[data-v-708f3ec1]{font-family:JetBrains Mono,Consolas,monospace;font-size:12px;word-break:break-all;color:var(--el-text-color-primary)}.fme-path-empty[data-v-708f3ec1]{color:var(--el-text-color-secondary);font-size:12px;font-style:italic}.fme-toolbar[data-v-708f3ec1]{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.fme-search[data-v-708f3ec1]{width:280px}.fme-btn-text[data-v-708f3ec1]{margin-left:4px}.fme-table-card[data-v-708f3ec1]{flex:1;display:flex;flex-direction:column;overflow:hidden}.fme-table-card[data-v-708f3ec1] .el-card__body{flex:1;display:flex;flex-direction:column;padding:0;overflow:hidden}.fme-table[data-v-708f3ec1]{flex:1}.fme-input[data-v-708f3ec1]{width:100%}.fme-locked[data-v-708f3ec1]{color:var(--el-text-color-secondary);font-style:italic}.pp-page[data-v-b5a5a932]{height:calc(100vh - 56px);padding:12px;box-sizing:border-box;display:flex;flex-direction:column}.pp-tabs[data-v-b5a5a932]{flex:1;display:flex;flex-direction:column;overflow:hidden}.pp-tabs[data-v-b5a5a932] .el-tabs__content{flex:1;overflow:hidden;padding:0}.pp-tabs[data-v-b5a5a932] .el-tab-pane{height:100%}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
import{aG as f,aA as w,bB as t,bm as h,g,b3 as v,aF as e,P as E,a4 as I,_ as k,aa as x,ab as z,T as U,O as V}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const W=f({__name:"RoutingPolicyView",setup(T){function _(r){return Object.entries(r).map(([s,a])=>({k:s,v:a}))}return(r,s)=>{const a=k,u=I,m=E,o=z,d=U,n=x,c=V;return v(),w(C,{section:"routing",title:"路径规划策略",description:"算法选择、权重、避障规则、区域限速(RoutingPolicy)",defaults:h(g)},{default:t(({payload:l,update:p})=>[e(c,{"label-width":"140px",model:l},{default:t(()=>[e(m,{label:"算法"},{default:t(()=>[e(u,{"model-value":l.algorithm,"onUpdate:modelValue":i=>p({...l,algorithm:i})},{default:t(()=>[e(a,{label:"Dijkstra",value:"dijkstra"}),e(a,{label:"A*",value:"astar"}),e(a,{label:"自定义",value:"custom"})]),_:1},8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(m,{label:"权重"},{default:t(()=>[e(n,{data:_(l.weights),size:"small"},{default:t(()=>[e(o,{label:"维度",prop:"k",width:"140"}),e(o,{label:"权重"},{default:t(i=>[e(d,{"model-value":i.row.v,min:0,max:10,step:.1,"onUpdate:modelValue":b=>p({...l,weights:{...l.weights,[i.row.k]:b??0}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024)]),_:2},1032,["data"])]),_:2},1024),e(m,{label:"避障规则"},{default:t(()=>[e(n,{data:l.avoidance,size:"small"},{default:t(()=>[e(o,{label:"ID",prop:"id",width:"100"}),e(o,{label:"区域 ID",prop:"zoneId",width:"140"}),e(o,{label:"规则",prop:"rule"})]),_:1},8,["data"])]),_:2},1024),e(m,{label:"区域限速"},{default:t(()=>[e(n,{data:l.zoneSpeedLimits,size:"small"},{default:t(()=>[e(o,{label:"区域",prop:"zoneId",width:"140"}),e(o,{label:"最大速度 (m/s)",prop:"maxSpeedMps"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{W as default};
|
||||
@@ -0,0 +1 @@
|
||||
.tpl-card[data-v-4faf66a8]{margin-bottom:12px}.tpl-title[data-v-4faf66a8]{font-weight:600;margin-bottom:6px}.tpl-meta[data-v-4faf66a8]{display:flex;gap:6px;margin-bottom:8px}.tpl-baseline[data-v-4faf66a8]{margin:0;font-size:11px;background:#f5f7fa;padding:6px;border-radius:4px;max-height:80px;overflow:auto}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as b,aA as _,bB as e,bm as C,h as E,b3 as i,aF as t,aC as h,bd as v,x as S,u as g,az as c,bj as a,ad as w,aE as o,ai as k,G as D,a2 as P,F as V,I as x,al as B}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const F={class:"tpl-title"},I={class:"tpl-meta"},L={class:"tpl-baseline"},N=b({__name:"ScenarioTemplateView",setup(z){return(A,R)=>{const r=w,d=g,m=S,p=P,f=x,l=D,u=V;return i(),_(T,{section:"scenario",title:"业务场景模板化",description:"SPS / Pack / 环线 / 点对点;DSL/低代码扩展(ScenarioTemplateConfig)",defaults:C(E)},{default:e(({payload:s})=>[t(p,{gutter:12},{default:e(()=>[(i(!0),h(k,null,v(s.templates,n=>(i(),_(m,{key:n.id,span:6},{default:e(()=>[t(d,{shadow:"hover",class:"tpl-card"},{default:e(()=>[c("div",F,a(n.name),1),c("div",I,[t(r,{size:"small"},{default:e(()=>[o(a(n.category),1)]),_:2},1024),t(r,{size:"small",type:"info"},{default:e(()=>[o("v"+a(n.version),1)]),_:2},1024)]),c("pre",L,a(n.baselineJson||"{}"),1)]),_:2},1024)]),_:2},1024))),128))]),_:2},1024),t(f),t(u,{column:2,border:""},{default:e(()=>[t(l,{label:"DSL 启用"},{default:e(()=>[o(a(s.dslPolicy.enabled?"是":"否"),1)]),_:2},1024),t(l,{label:"Schema 版本"},{default:e(()=>[o(a(s.dslPolicy.schemaVersion),1)]),_:2},1024),t(l,{label:"低代码"},{default:e(()=>[o(a(s.lowCode.enabled?"启用":"关闭"),1)]),_:2},1024),t(l,{label:"编辑器"},{default:e(()=>[o(a(s.lowCode.editor),1)]),_:2},1024),t(l,{label:"保留版本"},{default:e(()=>[o(a(s.versionPolicy.keepVersions),1)]),_:2},1024),t(l,{label:"允许回滚"},{default:e(()=>[o(a(s.versionPolicy.allowRollback?"是":"否"),1)]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}}),W=B(N,[["__scopeId","data-v-4faf66a8"]]);export{W as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{R as t}from"./ReflectionManagerPanel-BP1OYef-.js";import{aG as r,aC as o,aF as i,b3 as e,al as a}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const m={class:"script-page"},p=r({__name:"ScriptPanelView",setup(s){return(c,n)=>(e(),o("div",m,[i(t,{kind:"script","kind-label":"脚本",title:"脚本管理(CarProgram 运行实例 · 与 SimpleLite 工作台「脚本」页对齐)","empty-text":"当前无 CarProgram 实例。CarProgram 是 Mission 运行时编译用户脚本后产生的对象,要创建脚本请到「任务编排」页新建 Mission。","disable-create":"","disable-delete":"","show-summary-column":"","summary-label":"车辆","show-status-column":"","status-label":"状态","show-script-actions":""})]))}}),j=a(p,[["__scopeId","data-v-40d1e5dd"]]);export{j as default};
|
||||
@@ -0,0 +1 @@
|
||||
.script-page[data-v-40d1e5dd]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as w,b1 as E,b2 as k,aC as y,aF as s,bB as e,u as V,bb as x,b3 as z,F as C,G as D,aE as l,bj as f,bm as B,ad as I,az as m,s as M,bq as W,al as N}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css */const O={class:"status-page mg-content"},K={class:"status-actions"},R=w({__name:"ServiceStatusView",setup(T){const b=W(),_=new Date().toLocaleString("zh-CN"),i=x("00:00:00");let r;const S=Date.now();function u(){const p=Date.now()-S,t=Math.floor(p/1e3),o=String(Math.floor(t/3600)).padStart(2,"0"),a=String(Math.floor(t%3600/60)).padStart(2,"0"),d=String(t%60).padStart(2,"0");i.value=`${o}:${a}:${d}`}E(()=>{u(),r=window.setInterval(u,1e3)}),k(()=>{r&&clearInterval(r)});function v(){b.back()}return(p,t)=>{const o=I,a=D,d=C,n=M,g=V;return z(),y("div",O,[s(g,{shadow:"never",class:"status-card"},{header:e(()=>[t[1]||(t[1]=m("span",null,"SimpleLite Service Status",-1)),s(o,{type:"success",effect:"dark",style:{"margin-left":"8px"}},{default:e(()=>[...t[0]||(t[0]=[l("Web-Enabled (Mock)",-1)])]),_:1})]),default:e(()=>[s(d,{column:2,border:""},{default:e(()=>[s(a,{label:"模式"},{default:e(()=>[...t[2]||(t[2]=[l("Web-Enabled",-1)])]),_:1}),s(a,{label:"启动时间"},{default:e(()=>[l(f(B(_)),1)]),_:1}),s(a,{label:"运行时长"},{default:e(()=>[l(f(i.value),1)]),_:1}),s(a,{label:"节点角色"},{default:e(()=>[s(o,{type:"success"},{default:e(()=>[...t[3]||(t[3]=[l("Active (ROSE)",-1)])]),_:1})]),_:1}),s(a,{label:"WebAPI"},{default:e(()=>[t[5]||(t[5]=l("http://0.0.0.0:7001 ",-1)),s(o,{size:"small"},{default:e(()=>[...t[4]||(t[4]=[l("OK",-1)])]),_:1})]),_:1}),s(a,{label:"WebSocket"},{default:e(()=>[t[7]||(t[7]=l("ws://0.0.0.0:7002 ",-1)),s(o,{size:"small"},{default:e(()=>[...t[6]||(t[6]=[l("OK",-1)])]),_:1})]),_:1}),s(a,{label:"webVRender"},{default:e(()=>[t[9]||(t[9]=l("http://0.0.0.0:8223 ",-1)),s(o,{size:"small",type:"success"},{default:e(()=>[...t[8]||(t[8]=[l("OK",-1)])]),_:1})]),_:1}),s(a,{label:"Platform.Server"},{default:e(()=>[...t[10]||(t[10]=[l(":8080 (pid=12345)",-1)])]),_:1}),s(a,{label:"在线 Vue 客户端"},{default:e(()=>[...t[11]||(t[11]=[l("admin=3, monitor=4",-1)])]),_:1}),s(a,{label:"调度循环 / 任务"},{default:e(()=>[...t[12]||(t[12]=[l("50 Hz · 14 / 32",-1)])]),_:1})]),_:1}),m("div",K,[s(n,null,{default:e(()=>[...t[13]||(t[13]=[l("查看日志",-1)])]),_:1}),s(n,{type:"warning",plain:""},{default:e(()=>[...t[14]||(t[14]=[l("重启 Web",-1)])]),_:1}),s(n,{type:"danger",plain:""},{default:e(()=>[...t[15]||(t[15]=[l("关闭服务",-1)])]),_:1}),s(n,{onClick:v},{default:e(()=>[...t[16]||(t[16]=[l("返回",-1)])]),_:1})])]),_:1})])}}}),c=N(R,[["__scopeId","data-v-ddc086d3"]]);export{c as default};
|
||||
@@ -0,0 +1 @@
|
||||
.status-page[data-v-ddc086d3]{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px}.status-card[data-v-ddc086d3]{width:720px}.status-actions[data-v-ddc086d3]{display:flex;gap:8px;margin-top:16px;justify-content:flex-end}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.theme-customizer[data-v-8a725bd2]{margin-top:8px}.tc-hint[data-v-8a725bd2]{margin:0 0 12px;font-size:12px;color:var(--mg-text-dim, rgba(255, 255, 255, .55));line-height:1.5}.tc-title-row[data-v-8a725bd2]{display:flex;align-items:center;gap:10px;width:100%}.tc-swatch[data-v-8a725bd2]{width:22px;height:22px;border-radius:6px;flex-shrink:0;box-shadow:0 2px 6px #00000059}.tc-name[data-v-8a725bd2]{font-weight:600;color:var(--mg-text-light, #fff)}.tc-panel[data-v-8a725bd2]{padding:4px 8px 12px}.tc-picker-row[data-v-8a725bd2]{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.tc-hex[data-v-8a725bd2]{font-family:ui-monospace,monospace;font-size:12px;color:var(--mg-text-muted, rgba(255, 255, 255, .75))}.tc-field-hint[data-v-8a725bd2]{font-size:11px;color:var(--mg-text-dim, rgba(255, 255, 255, .45))}.tc-actions[data-v-8a725bd2]{display:flex;gap:8px;margin-top:8px;padding-left:100px}.tc-footer[data-v-8a725bd2]{margin-top:16px}.ai-service-card[data-v-b66101c5]{margin:12px 0}.ml-8[data-v-b66101c5]{margin-left:8px}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as p,aA as f,bB as o,bm as c,j as b,b3 as V,aF as e,P as x,a1 as B,a0 as E,aE as n,a8 as U,T as v,O as C}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const O=p({__name:"TaskAllocationView",setup(k){return(w,l)=>{const m=E,d=B,r=x,s=U,i=v,_=C;return V(),f(T,{section:"task",title:"任务分配机制",description:"负载均衡、就近分配、优先级调度(TaskAllocationPolicy)",defaults:c(b)},{default:o(({payload:t,update:u})=>[e(_,{"label-width":"160px",model:t},{default:o(()=>[e(r,{label:"分配模式"},{default:o(()=>[e(d,{"model-value":t.mode,"onUpdate:modelValue":a=>u({...t,mode:a})},{default:o(()=>[e(m,{label:"roundRobin"},{default:o(()=>[...l[0]||(l[0]=[n("轮询",-1)])]),_:1}),e(m,{label:"nearest"},{default:o(()=>[...l[1]||(l[1]=[n("就近",-1)])]),_:1}),e(m,{label:"leastLoad"},{default:o(()=>[...l[2]||(l[2]=[n("最少负载",-1)])]),_:1}),e(m,{label:"custom"},{default:o(()=>[...l[3]||(l[3]=[n("自定义",-1)])]),_:1})]),_:1},8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(r,{label:"启用负载均衡"},{default:o(()=>[e(s,{"model-value":t.loadBalance,"onUpdate:modelValue":a=>u({...t,loadBalance:!!a})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),e(r,{label:"单车队列上限"},{default:o(()=>[e(i,{"model-value":t.maxQueuePerCar,min:1,max:100,"onUpdate:modelValue":a=>u({...t,maxQueuePerCar:a??0})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{O as default};
|
||||
@@ -0,0 +1 @@
|
||||
.scene-mgr-page[data-v-ce41515a]{height:calc(100vh - 56px);padding:12px 14px;box-sizing:border-box;display:flex;flex-direction:column}.scene-tabs[data-v-ce41515a]{flex:1;display:flex;flex-direction:column;overflow:hidden}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as m,b3 as _,aC as k,aF as e,bB as a,a9 as x,ac as g,bb as l,al as T,aA as v}from"./index-DrvLDslf.js";/* empty css *//* empty css */import{R as s}from"./ReflectionManagerPanel-BP1OYef-.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./reflection-Dr3WM5Rz.js";import"./useMapEditStream-DPAdbySW.js";import"./useProjectionStream-DMf_8m8X.js";const y={class:"scene-mgr-page"},P=m({__name:"SceneManagerView",setup(f){const r=l("site"),o=l(null),p=l(null),c=l(null);function u(t){(t==="site"?o.value:t==="track"?p.value:t==="special"?c.value:null)?.refresh?.()}return(t,n)=>{const i=x,d=g;return _(),k("div",y,[e(d,{modelValue:r.value,"onUpdate:modelValue":n[0]||(n[0]=b=>r.value=b),type:"border-card",class:"scene-tabs admin-tabs",onTabChange:u},{default:a(()=>[e(i,{label:"站点",name:"site"},{default:a(()=>[e(s,{ref_key:"sitePanelRef",ref:o,kind:"site","kind-label":"站点",title:"站点管理(Site / UISite,含禁用/启用、必空点等动作)","empty-text":"当前没有站点;点击右上角「新建站点」按 x/y 坐标添加。"},null,512)]),_:1}),e(i,{label:"路径",name:"track"},{default:a(()=>[e(s,{ref_key:"trackPanelRef",ref:p,kind:"track","kind-label":"路径",title:"路径管理(Track / UITrack,含方向、冲突、投影、二分等动作)","empty-text":"当前没有路径;点击右上角「新建路径」选起止站点添加。"},null,512)]),_:1}),e(i,{label:"装饰物",name:"special"},{default:a(()=>[e(s,{ref_key:"specialPanelRef",ref:c,kind:"special","kind-label":"装饰物",title:"装饰物管理(UI_Image / UI_Text / UI_Model)","empty-text":"当前没有装饰物;新建图片/文本/模型可用底部按钮,或先在地图编辑里上传资产。"},null,512)]),_:1})]),_:1},8,["modelValue"])])}}}),R=T(P,[["__scopeId","data-v-ce41515a"]]),Y=m({__name:"TrackTableView",setup(f){return(r,o)=>(_(),v(R))}});export{Y as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as d,aA as m,bB as e,bm as _,k as c,b3 as f,aF as a,a9 as b,aa as u,ab as w,aE as r,bj as p,ac as I}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";/* empty css *//* empty css *//* empty css */import{C as T}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const G=d({__name:"TrafficRuleView",setup(h){return(x,D)=>{const t=w,o=u,l=b,n=I;return f(),m(T,{section:"traffic",title:"交通管制规则",description:"路口策略、区域互斥、动态让行(TrafficRule)",defaults:_(c)},{default:e(({payload:i})=>[a(n,{"model-value":"ix"},{default:e(()=>[a(l,{name:"ix",label:"路口策略"},{default:e(()=>[a(o,{data:i.intersections,size:"small"},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"100"}),a(t,{label:"站点 IDs",prop:"siteIds"},{default:e(s=>[r(p(s.row.siteIds.join(", ")),1)]),_:1}),a(t,{label:"模式",prop:"mode",width:"100"})]),_:1},8,["data"])]),_:2},1024),a(l,{name:"mz",label:"区域互斥"},{default:e(()=>[a(o,{data:i.mutex,size:"small"},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"100"}),a(t,{label:"区域 IDs",prop:"zoneIds"},{default:e(s=>[r(p(s.row.zoneIds.join(", ")),1)]),_:1})]),_:1},8,["data"])]),_:2},1024),a(l,{name:"yd",label:"动态让行"},{default:e(()=>[a(o,{data:i.yields,size:"small"},{default:e(()=>[a(t,{label:"ID",prop:"id",width:"100"}),a(t,{label:"From",prop:"from",width:"140"}),a(t,{label:"To",prop:"to",width:"140"}),a(t,{label:"条件",prop:"condition"})]),_:1},8,["data"])]),_:2},1024)]),_:2},1024)]),_:1},8,["defaults"])}}});export{G as default};
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as c,aA as f,bB as t,bm as b,l as V,b3 as v,aF as o,P as U,a5 as h,I as R,aE as d,a8 as B,S as E,T as w,O as T}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css *//* empty css */import"./el-tooltip-l0sNRNKZ.js";import{C as x}from"./ConfigPageBase-CPF7i-FB.js";/* empty css *//* empty css *//* empty css *//* empty css */import"./PermissionGuard-BxagL6-4.js";import"./reflection-Dr3WM5Rz.js";const z=c({__name:"VehicleMaintenanceView",setup(C){return(I,m)=>{const i=h,a=U,r=R,u=B,p=E,s=w,_=T;return v(),f(x,{section:"vehicle",title:"车辆维护策略",description:"电量阈值、故障上报、自动报修(VehicleMaintenancePolicy)",defaults:b(V)},{default:t(({payload:e,update:n})=>[o(_,{"label-width":"160px",model:e},{default:t(()=>[o(a,{label:"低电量阈值"},{default:t(()=>[o(i,{"model-value":e.lowBatteryThreshold*100,min:0,max:100,"format-tooltip":l=>`${l}%`,"onUpdate:modelValue":l=>n({...e,lowBatteryThreshold:(Number(l)||0)/100})},null,8,["model-value","format-tooltip","onUpdate:modelValue"])]),_:2},1024),o(a,{label:"临界电量阈值"},{default:t(()=>[o(i,{"model-value":e.criticalBatteryThreshold*100,min:0,max:100,"format-tooltip":l=>`${l}%`,"onUpdate:modelValue":l=>n({...e,criticalBatteryThreshold:(Number(l)||0)/100})},null,8,["model-value","format-tooltip","onUpdate:modelValue"])]),_:2},1024),o(r,{"content-position":"left"},{default:t(()=>[...m[0]||(m[0]=[d("故障上报",-1)])]),_:1}),o(a,{label:"启用"},{default:t(()=>[o(u,{"model-value":e.faultReport.enabled,"onUpdate:modelValue":l=>n({...e,faultReport:{...e.faultReport,enabled:!!l}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),o(a,{label:"接收邮箱"},{default:t(()=>[o(p,{"model-value":e.faultReport.emailTo.join(", "),"onUpdate:modelValue":l=>n({...e,faultReport:{...e.faultReport,emailTo:l.split(/[,\s]+/).filter(Boolean)}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),o(r,{"content-position":"left"},{default:t(()=>[...m[1]||(m[1]=[d("自动报修",-1)])]),_:1}),o(a,{label:"启用"},{default:t(()=>[o(u,{"model-value":e.autoRepair.enabled,"onUpdate:modelValue":l=>n({...e,autoRepair:{...e.autoRepair,enabled:!!l}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024),o(a,{label:"冷却时间 (秒)"},{default:t(()=>[o(s,{"model-value":e.autoRepair.cooldownSec,min:0,max:86400,"onUpdate:modelValue":l=>n({...e,autoRepair:{...e.autoRepair,cooldownSec:l??0}})},null,8,["model-value","onUpdate:modelValue"])]),_:2},1024)]),_:2},1032,["model"])]),_:1},8,["defaults"])}}});export{z as default};
|
||||
@@ -0,0 +1 @@
|
||||
.el-slider{--el-slider-main-bg-color:var(--el-color-primary);--el-slider-runway-bg-color:var(--el-border-color-light);--el-slider-stop-bg-color:var(--el-color-white);--el-slider-disabled-color:var(--el-text-color-placeholder);--el-slider-border-radius:3px;--el-slider-height:6px;--el-slider-button-size:20px;--el-slider-button-wrapper-size:36px;--el-slider-button-wrapper-offset:-15px;align-items:center;width:100%;height:32px;display:flex}.el-slider__runway{height:var(--el-slider-height);background-color:var(--el-slider-runway-bg-color);border-radius:var(--el-slider-border-radius);cursor:pointer;flex:1;position:relative}.el-slider__runway.show-input{width:auto;margin-right:30px}.el-slider__runway.is-disabled{cursor:default}.el-slider__runway.is-disabled .el-slider__bar{background-color:var(--el-slider-disabled-color)}.el-slider__runway.is-disabled .el-slider__button{border-color:var(--el-slider-disabled-color)}.el-slider__runway.is-disabled .el-slider__button-wrapper:hover,.el-slider__runway.is-disabled .el-slider__button-wrapper.hover,.el-slider__runway.is-disabled .el-slider__button-wrapper.dragging{cursor:not-allowed}.el-slider__runway.is-disabled .el-slider__button:hover,.el-slider__runway.is-disabled .el-slider__button.hover,.el-slider__runway.is-disabled .el-slider__button.dragging{cursor:not-allowed;transform:scale(1)}.el-slider__input{flex-shrink:0;width:130px}.el-slider__bar{height:var(--el-slider-height);background-color:var(--el-slider-main-bg-color);border-top-left-radius:var(--el-slider-border-radius);border-bottom-left-radius:var(--el-slider-border-radius);position:absolute}.el-slider__button-wrapper{height:var(--el-slider-button-wrapper-size);width:var(--el-slider-button-wrapper-size);z-index:1;top:var(--el-slider-button-wrapper-offset);text-align:center;-webkit-user-select:none;user-select:none;background-color:#0000;outline:none;line-height:normal;position:absolute;transform:translate(-50%)}.el-slider__button-wrapper:after{content:"";vertical-align:middle;height:100%;display:inline-block}.el-slider__button-wrapper:hover,.el-slider__button-wrapper.hover{cursor:grab}.el-slider__button-wrapper.dragging{cursor:grabbing}.el-slider__button{width:var(--el-slider-button-size);height:var(--el-slider-button-size);vertical-align:middle;border:solid 2px var(--el-slider-main-bg-color);background-color:var(--el-color-white);box-sizing:border-box;transition:var(--el-transition-duration-fast);-webkit-user-select:none;user-select:none;border-radius:50%;display:inline-block}.el-slider__button:hover,.el-slider__button.hover,.el-slider__button.dragging{transform:scale(1.2)}.el-slider__button:hover,.el-slider__button.hover{cursor:grab}.el-slider__button.dragging{cursor:grabbing}.el-slider__stop{height:var(--el-slider-height);width:var(--el-slider-height);border-radius:var(--el-border-radius-circle);background-color:var(--el-slider-stop-bg-color);position:absolute;transform:translate(-50%)}.el-slider__marks{width:18px;height:100%;top:0;left:12px}.el-slider__marks-text{color:var(--el-color-info);white-space:pre;margin-top:15px;font-size:14px;position:absolute;transform:translate(-50%)}.el-slider.is-vertical{flex:0;width:auto;height:100%;display:inline-flex;position:relative}.el-slider.is-vertical .el-slider__runway{width:var(--el-slider-height);height:100%;margin:0 16px}.el-slider.is-vertical .el-slider__bar{width:var(--el-slider-height);border-radius:0 0 3px 3px;height:auto}.el-slider.is-vertical .el-slider__button-wrapper{top:auto;left:var(--el-slider-button-wrapper-offset);transform:translateY(50%)}.el-slider.is-vertical .el-slider__stop{transform:translateY(50%)}.el-slider.is-vertical .el-slider__marks-text{margin-top:0;left:15px;transform:translateY(50%)}.el-slider--large{height:40px}.el-slider--small{height:24px}
|
||||
@@ -0,0 +1 @@
|
||||
.workspace-3d-wrap[data-v-fdbecbc9]{display:flex;flex-direction:column;height:100%;min-height:480px;border:1px solid rgba(255,255,255,.12);border-radius:var(--mg-radius, 16px);background:#0f042073;backdrop-filter:blur(18px) saturate(140%);-webkit-backdrop-filter:blur(18px) saturate(140%);box-shadow:0 8px 24px #0a021059,0 0 0 1px #ffffff0f inset;overflow:hidden;transition:all .25s cubic-bezier(.25,.8,.25,1)}.workspace-3d-wrap.workspace-3d--canvas-only[data-v-fdbecbc9]{border:0;border-radius:0;min-height:0;background:transparent;box-shadow:none}.workspace-3d-toolbar[data-v-fdbecbc9]{display:flex;align-items:center;gap:8px;padding:8px 14px;background:#ffffff0a;border-bottom:1px solid rgba(255,255,255,.08);font-size:12px;color:#e8d7f5d9}.workspace-3d-toolbar .hint[data-v-fdbecbc9]{color:#c9a6e3b3;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:11.5px}.workspace-3d-toolbar .spacer[data-v-fdbecbc9]{flex:1}.workspace-3d-frame[data-v-fdbecbc9]{flex:1;position:relative;background:radial-gradient(ellipse at 50% 40%,#2d1456,#1a0930,#0f0420)}.workspace-3d-iframe[data-v-fdbecbc9]{width:100%;height:100%;border:0;background:transparent;display:block}.workspace-3d-mask[data-v-fdbecbc9]{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;background:radial-gradient(ellipse at center,rgba(var(--mg-primary-rgb),.35),rgba(var(--mg-bg-app-deep-rgb),.92) 80%),rgba(var(--mg-bg-app-deep-rgb),.85);color:#e8dcffeb;font-size:13px;backdrop-filter:blur(10px)}.workspace-3d-mask .muted[data-v-fdbecbc9]{color:rgba(var(--mg-accent-rgb),.7);font-size:12px}.workspace-3d-mask .el-icon[data-v-fdbecbc9]{color:var(--mg-accent);filter:drop-shadow(0 0 10px rgba(var(--mg-accent-rgb),.6))}
|
||||
@@ -0,0 +1 @@
|
||||
import{aG as N,b1 as P,b0 as R,aC as m,aZ as $,aF as c,ad as A,bB as o,az as d,bj as l,aA as C,aB as i,s as D,bm as _,bc as I,aO as j,R as q,bb as n,av as S,b3 as r,aE as u,aU as G,al as M}from"./index-DrvLDslf.js";/* empty css *//* empty css *//* empty css */const H={key:0,class:"workspace-3d-toolbar"},Z={class:"hint"},J=["src"],K={key:1,class:"workspace-3d-mask"},Q=N({__name:"Workspace3D",props:{host:{default:void 0},scope:{default:"Platform"},token:{default:""},readOnly:{type:Boolean,default:!1},embedUi:{type:Boolean,default:!1},canvasOnly:{type:Boolean,default:!1},hideToolbar:{type:Boolean,default:!1}},emits:["pick","select","ready"],setup(s,{expose:U,emit:W}){const t=s,y=W,v=n(),g=n(),k=n(!1),f=n(null),b=n([]),w=n(""),p=S(()=>t.host??"localhost:8223"??"localhost:8223"),L=S(()=>{const a=new URLSearchParams;return a.set("scope",t.scope??"Platform"),t.token&&a.set("token",t.token),a.set("ro",t.readOnly?"1":"0"),t.canvasOnly?a.set("ui","canvas-only"):t.embedUi&&a.set("ui","embed"),`http://${p.value}/?${a.toString()}`});async function F(){const a=t.canvasOnly?"/declareCanvasOnly":t.embedUi?"/declareEmbedUi":null;if(a)try{await fetch(`http://${p.value}${a}`,{method:"GET",mode:"no-cors",cache:"no-store",credentials:"omit",keepalive:!0})}catch(e){console.warn(`[Workspace3D] ${a} 请求失败,SimpleLite 可能仍以完整面板模式启动:`,e)}}async function B(){k.value=!1,await F(),w.value=L.value}function T(){k.value=!0,y("ready")}function E(a){if(!v.value||a.source!==v.value.contentWindow)return;const e=a.data;!e||typeof e!="object"||(e.type==="workspace.pick"&&e.payload&&typeof e.payload.x=="number"?(f.value=e.payload,y("pick",e.payload)):e.type==="workspace.select"&&Array.isArray(e.payload)&&(b.value=e.payload,y("select",e.payload)))}function x(){B()}function O(){const a=g.value;a&&(document.fullscreenElement?document.exitFullscreen():a.requestFullscreen())}return P(()=>{window.addEventListener("message",E),B()}),R(()=>{window.removeEventListener("message",E)}),U({reload:x,enterFullscreen:O}),(a,e)=>{const h=A,z=D,V=q;return r(),m("div",{class:$(["workspace-3d-wrap",{"workspace-3d--canvas-only":s.canvasOnly||s.hideToolbar}])},[s.canvasOnly||s.hideToolbar?i("",!0):(r(),m("div",H,[c(h,{type:s.readOnly?"info":"success",effect:"dark",size:"small"},{default:o(()=>[u(l(s.readOnly?"只读":"可交互"),1)]),_:1},8,["type"]),d("span",Z,"webVRender · http://"+l(p.value)+" · scope="+l(s.scope),1),e[2]||(e[2]=d("div",{class:"spacer"},null,-1)),f.value?(r(),C(h,{key:0,type:"warning",size:"small"},{default:o(()=>[u(" Pick ("+l(f.value.x.toFixed(0))+", "+l(f.value.y.toFixed(0))+") ",1)]),_:1})):i("",!0),b.value.length?(r(),C(h,{key:1,type:"primary",size:"small"},{default:o(()=>[u(" Selected: "+l(b.value.length),1)]),_:1})):i("",!0),c(z,{size:"small",icon:_(I),onClick:x},{default:o(()=>[...e[0]||(e[0]=[u("重载",-1)])]),_:1},8,["icon"]),c(z,{size:"small",icon:_(j),onClick:O},{default:o(()=>[...e[1]||(e[1]=[u("全屏",-1)])]),_:1},8,["icon"])])),d("div",{ref_key:"frameWrap",ref:g,class:"workspace-3d-frame"},[w.value?(r(),m("iframe",{key:0,ref_key:"frame",ref:v,src:w.value,class:"workspace-3d-iframe",allow:"fullscreen",onLoad:T},null,40,J)):i("",!0),k.value?i("",!0):(r(),m("div",K,[c(V,{class:"is-loading",size:"32"},{default:o(()=>[c(_(G))]),_:1}),d("span",null,"正在连接 webVRender (http://"+l(p.value)+") ...",1),e[3]||(e[3]=d("span",{class:"muted"},"如长时间未加载,请确认 SimpleLite 已以 Web-Enabled 模式启动且 8223 端口可达。",-1))]))],512)],2)}}}),te=M(Q,[["__scopeId","data-v-fdbecbc9"]]);export{te as W};
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
.canvas-toolbar[data-v-8e85cbc4]{display:flex;align-items:center;gap:6px;padding:6px 10px;background:#00000073;border-top:1px solid rgba(255,255,255,.1);border-bottom-left-radius:var(--mg-radius, 16px);border-bottom-right-radius:var(--mg-radius, 16px);backdrop-filter:blur(10px) saturate(140%);-webkit-backdrop-filter:blur(10px) saturate(140%);flex-wrap:wrap}.canvas-toolbar[data-v-8e85cbc4] .el-button{display:inline-flex;align-items:center;gap:4px}.btn-label[data-v-8e85cbc4]{margin-left:2px}.caret[data-v-8e85cbc4]{margin-left:2px;font-size:10px;opacity:.7}.separator[data-v-8e85cbc4]{width:1px;height:18px;background:#ffffff2e;margin:0 4px}.toolbar-menu .menu-row[data-v-8e85cbc4]{display:flex;align-items:center;gap:8px;padding:5px 14px;cursor:pointer;user-select:none;font-size:13px;color:var(--el-text-color-primary)}.toolbar-menu .menu-row[data-v-8e85cbc4]:hover{background:var(--el-fill-color-light)}.toolbar-menu .menu-row[data-v-8e85cbc4] .el-checkbox{margin-right:0;pointer-events:none}.layer-menu[data-v-8e85cbc4]{max-height:320px;overflow-y:auto}.menu-empty[data-v-8e85cbc4]{padding:10px 14px;color:var(--el-text-color-secondary);font-size:12px}.muted-hint[data-v-8e85cbc4]{margin:0 0 8px;color:var(--el-text-color-secondary);font-size:12px}
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user