重构页面目录与 RBAC:扁平配置入口并对齐管理/运营页映射。
下线运营总览幽灵页,补 ExpandKeysForScope / PlatformToMonitor,同步导航与角色配置 UI。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+124
-18
@@ -31,8 +31,12 @@ public static class PageCatalog
|
||||
new("admin-dashboard", "总览", "概览", ScopePlatform),
|
||||
new("admin-setup", "初始配置", "概览", ScopePlatform),
|
||||
new("admin-map-monitor", "地图监控", "概览", ScopePlatform),
|
||||
new("admin-playback", "调度回放", "概览", ScopePlatform),
|
||||
new("admin-tasks", "任务管理", "概览", ScopePlatform),
|
||||
new("admin-alarms", "报警管理", "概览", ScopePlatform),
|
||||
new("admin-ops-log", "运维记录", "概览", ScopePlatform),
|
||||
new("admin-notes", "运维备注", "概览", ScopePlatform),
|
||||
new("admin-logs", "日志管理", "概览", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:设计与编排 ──
|
||||
new("admin-maps", "地图管理", "设计与编排", ScopePlatform),
|
||||
@@ -50,20 +54,18 @@ public static class PageCatalog
|
||||
new("admin-data-center", "数据中心", "数据中心", ScopePlatform),
|
||||
|
||||
// ── 管理端 / Platform:平台配置中心(聚合页,每个 Key 对齐前端聚合路由 route.name) ──
|
||||
new("admin-config-strategy", "调度策略", "平台配置中心", ScopePlatform),
|
||||
new("admin-vehicle-hub", "车辆运维", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-facility", "设备接入", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-warehouse", "库位管理", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-business", "业务与集成", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-ops-center", "运维与回放", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-system-center", "系统与权限", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-ops-center", "监控配置", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-system", "系统配置", "平台配置中心", ScopePlatform),
|
||||
new("admin-config-auth", "权限与角色", "平台配置中心", ScopePlatform),
|
||||
|
||||
// ── 运营端 / RCSMonitor ──
|
||||
new("monitor-dashboard", "运营总览", "运营监控", ScopeMonitor),
|
||||
new("monitor-vehicle-hub", "车辆运维", "运营监控", ScopeMonitor),
|
||||
new("monitor-map", "地图监控", "运营监控", ScopeMonitor),
|
||||
new("monitor-ops", "运维操作", "运营监控", ScopeMonitor),
|
||||
new("monitor-notes", "运营备注", "运营监控", ScopeMonitor),
|
||||
new("monitor-ops", "运维记录", "运营监控", ScopeMonitor),
|
||||
new("monitor-notes", "运维备注", "运营监控", ScopeMonitor),
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> _keys =
|
||||
@@ -76,21 +78,14 @@ public static class PageCatalog
|
||||
["admin-config-vehicle"] = "admin-vehicle-hub",
|
||||
["admin-config-fleet"] = "admin-vehicle-hub",
|
||||
// 会话 16:平台配置中心入口按业务收敛为 6 个聚合页,旧 Key 迁移到对应聚合页 Key。
|
||||
["admin-config-routing"] = "admin-config-strategy",
|
||||
["admin-config-task"] = "admin-config-strategy",
|
||||
["admin-config-traffic"] = "admin-config-strategy",
|
||||
["admin-config-charge"] = "admin-config-strategy",
|
||||
["admin-config-device"] = "admin-config-facility",
|
||||
["admin-config-location"] = "admin-config-warehouse",
|
||||
["admin-config-integrations"] = "admin-config-business",
|
||||
["admin-config-scenario"] = "admin-config-business",
|
||||
["admin-config-widget"] = "admin-config-business",
|
||||
["admin-playback"] = "admin-config-ops-center",
|
||||
["admin-config-ops"] = "admin-config-ops-center",
|
||||
["admin-config-logs"] = "admin-config-ops-center",
|
||||
["admin-config-logs"] = "admin-logs",
|
||||
["admin-config-map-monitor"] = "admin-config-ops-center",
|
||||
["admin-config-system"] = "admin-config-system-center",
|
||||
["admin-config-auth"] = "admin-config-system-center",
|
||||
["admin-config-system-center"] = "admin-config-system",
|
||||
// 运营总览页已下线:旧角色勾选迁到地图监控,避免权限清单里出现幽灵页面。
|
||||
["monitor-dashboard"] = "monitor-map",
|
||||
["admin-data-center-stations"] = "admin-data-center",
|
||||
["admin-data-center-docks"] = "admin-data-center",
|
||||
["admin-data-center-handshake"] = "admin-data-center",
|
||||
@@ -98,6 +93,64 @@ public static class PageCatalog
|
||||
["admin-data-center-mag-control"] = "admin-data-center",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 管理端页面 → 运营端对应页面。给运营角色勾页时,只能从「当前管理员已有的管理端页」映射出来。
|
||||
/// 无对应运营页的管理端入口(如任务/报警)不出现在映射里。
|
||||
/// </summary>
|
||||
public static readonly IReadOnlyDictionary<string, string> PlatformToMonitor =
|
||||
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["admin-map-monitor"] = "monitor-map",
|
||||
["admin-vehicle-hub"] = "monitor-vehicle-hub",
|
||||
["admin-ops-log"] = "monitor-ops",
|
||||
["admin-notes"] = "monitor-notes",
|
||||
};
|
||||
|
||||
/// <summary>仅运营端有的页面:不依赖管理端对应页,始终可授给运营角色。</summary>
|
||||
public static readonly IReadOnlyList<string> MonitorOnlyPages =
|
||||
new[] { "monitor-ops", "monitor-notes" };
|
||||
|
||||
/// <summary>
|
||||
/// 把角色里勾选的页面展开到指定登录域:勾了运营监控的「运维记录」时,
|
||||
/// 管理账号/通用角色进管理壳也能拿到对应的 admin-ops-log。
|
||||
/// </summary>
|
||||
public static HashSet<string> ExpandKeysForScope(IEnumerable<string> keys, string scope)
|
||||
{
|
||||
var scopeKeys = KeysForScope(scope).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var raw in keys ?? Array.Empty<string>())
|
||||
{
|
||||
if (string.Equals(raw, Wildcard, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var k in scopeKeys) set.Add(k);
|
||||
return set;
|
||||
}
|
||||
var key = NormalizeKey(raw);
|
||||
if (scopeKeys.Contains(key)) set.Add(key);
|
||||
var counterpart = CounterpartForScope(key, scope);
|
||||
if (counterpart != null && scopeKeys.Contains(counterpart)) set.Add(counterpart);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>管理页 ↔ 运营页互认:monitor-ops → admin-ops-log(当前域是 Platform 时)。</summary>
|
||||
public static string? CounterpartForScope(string key, string targetScope)
|
||||
{
|
||||
if (string.Equals(targetScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
foreach (var kv in PlatformToMonitor)
|
||||
{
|
||||
if (string.Equals(kv.Value, key, StringComparison.OrdinalIgnoreCase))
|
||||
return kv.Key;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (string.Equals(targetScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase)
|
||||
&& PlatformToMonitor.TryGetValue(key, out var monitorKey))
|
||||
return monitorKey;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>判断页面 Key 是否合法(用于角色保存时过滤掉脏数据 / 已下线页面)。</summary>
|
||||
public static bool IsValidKey(string key) => _keys.Contains(key);
|
||||
|
||||
@@ -110,4 +163,57 @@ public static class PageCatalog
|
||||
All.Where(p => string.Equals(p.Scope, scope, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(p => p.Key)
|
||||
.ToList();
|
||||
|
||||
/// <summary>当前管理员在管理端拥有的页面 → 可授给运营端角色的页面集合。</summary>
|
||||
public static HashSet<string> GrantableMonitorPages(IEnumerable<string>? actorPlatformPages)
|
||||
{
|
||||
var set = new HashSet<string>(MonitorOnlyPages, StringComparer.OrdinalIgnoreCase);
|
||||
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
|
||||
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase)
|
||||
|| HasAllKeys(list, KeysForScope(ScopePlatform)))
|
||||
{
|
||||
foreach (var k in KeysForScope(ScopeMonitor)) set.Add(k);
|
||||
return set;
|
||||
}
|
||||
foreach (var p in list)
|
||||
{
|
||||
var key = NormalizeKey(p);
|
||||
if (PlatformToMonitor.TryGetValue(key, out var monitorKey) && IsValidKey(monitorKey))
|
||||
set.Add(monitorKey);
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
/// <summary>当前管理员可授给管理端角色的页面(不能超权:只能授自己已有的)。</summary>
|
||||
public static HashSet<string> GrantablePlatformPages(IEnumerable<string>? actorPlatformPages)
|
||||
{
|
||||
var list = (actorPlatformPages ?? Array.Empty<string>()).ToList();
|
||||
var all = KeysForScope(ScopePlatform);
|
||||
if (list.Contains(Wildcard, StringComparer.OrdinalIgnoreCase) || HasAllKeys(list, all))
|
||||
return all.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return list
|
||||
.Select(NormalizeKey)
|
||||
.Where(k => IsValidKey(k) && all.Contains(k, StringComparer.OrdinalIgnoreCase))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>按角色归属域,算出当前管理员可勾选的页面 Key。</summary>
|
||||
public static HashSet<string> GrantablePagesForRoleScope(
|
||||
string roleScope, IEnumerable<string>? actorPlatformPages)
|
||||
{
|
||||
if (string.Equals(roleScope, ScopeMonitor, StringComparison.OrdinalIgnoreCase))
|
||||
return GrantableMonitorPages(actorPlatformPages);
|
||||
if (string.Equals(roleScope, ScopePlatform, StringComparison.OrdinalIgnoreCase))
|
||||
return GrantablePlatformPages(actorPlatformPages);
|
||||
// 通用域:管理端可授 + 运营端可授
|
||||
var set = GrantablePlatformPages(actorPlatformPages);
|
||||
set.UnionWith(GrantableMonitorPages(actorPlatformPages));
|
||||
return set;
|
||||
}
|
||||
|
||||
private static bool HasAllKeys(IEnumerable<string> have, IEnumerable<string> all)
|
||||
{
|
||||
var set = have.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
return all.All(k => set.Contains(k));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace MiGu.Server.Auth;
|
||||
///
|
||||
/// 首次启动(rbac.json 不存在)时 seed 两个内置账号:
|
||||
/// admin(超级管理员,scope=*,全部页面 / 操作)
|
||||
/// ops (运营人员,scope=RCSMonitor,运营四页 + 运维操作码)
|
||||
/// ops (运营人员,scope=RCSMonitor,运营监控页 + 运维操作码)
|
||||
/// 初始密码取 appsettings <c>Auth:Users:{name}:Password</c>,缺省 admin/ops(开发弱口令,生产须改)。
|
||||
/// </summary>
|
||||
public sealed class RbacStore
|
||||
@@ -58,9 +58,14 @@ public sealed class RbacStore
|
||||
var snap = JsonSerializer.Deserialize<RbacSnapshot>(json, _jsonOpts);
|
||||
if (snap is { Users.Count: > 0 })
|
||||
{
|
||||
_snapshot = Normalize(snap);
|
||||
_snapshot = Normalize(snap, out var migrated);
|
||||
_logger.LogInformation("RBAC 从 {File} 载入:{Users} 用户 / {Roles} 角色。",
|
||||
_file, _snapshot.Users.Count, _snapshot.Roles.Count);
|
||||
if (migrated)
|
||||
{
|
||||
Persist();
|
||||
_logger.LogInformation("RBAC 已将下线页面 Key 迁移并写回 {File}。", _file);
|
||||
}
|
||||
return;
|
||||
}
|
||||
_logger.LogWarning("RBAC 文件 {File} 内容为空或无用户,回退到默认 seed。", _file);
|
||||
@@ -82,21 +87,30 @@ public sealed class RbacStore
|
||||
}
|
||||
|
||||
/// <summary>清洗加载结果:补默认、去重、过滤非法页面 Key,保证内置角色存在。</summary>
|
||||
private static RbacSnapshot Normalize(RbacSnapshot snap)
|
||||
/// <param name="migrated">任一角色页面 Key 被别名替换 / 丢弃 / 补齐时为 true,调用方应落盘。</param>
|
||||
private static RbacSnapshot Normalize(RbacSnapshot snap, out bool migrated)
|
||||
{
|
||||
migrated = false;
|
||||
snap.Roles ??= new();
|
||||
snap.Users ??= new();
|
||||
foreach (var r in snap.Roles)
|
||||
{
|
||||
r.Pages = (r.Pages ?? new())
|
||||
var before = r.Pages ?? new();
|
||||
var hadSystemHub = before.Any(p =>
|
||||
string.Equals(p, "admin-config-system-center", StringComparison.OrdinalIgnoreCase));
|
||||
var after = before
|
||||
.Select(p => p == PageCatalog.Wildcard ? p : PageCatalog.NormalizeKey(p))
|
||||
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
if (!SamePages(before, after)) migrated = true;
|
||||
r.Pages = after;
|
||||
r.Ops = (r.Ops ?? new()).Distinct().ToList();
|
||||
r.WidgetGrants ??= new();
|
||||
if (string.IsNullOrWhiteSpace(r.Scope)) r.Scope = PageCatalog.ScopePlatform;
|
||||
BackfillKnownPageMigrations(r);
|
||||
var countBeforeBackfill = r.Pages.Count;
|
||||
BackfillKnownPageMigrations(r, hadSystemHub);
|
||||
if (r.Pages.Count != countBeforeBackfill) migrated = true;
|
||||
}
|
||||
foreach (var u in snap.Users)
|
||||
{
|
||||
@@ -105,7 +119,12 @@ public sealed class RbacStore
|
||||
return snap;
|
||||
}
|
||||
|
||||
private static void BackfillKnownPageMigrations(RbacRole r)
|
||||
private static bool SamePages(IReadOnlyList<string> a, IReadOnlyList<string> b) =>
|
||||
a.Count == b.Count
|
||||
&& !a.Except(b, StringComparer.OrdinalIgnoreCase).Any()
|
||||
&& !b.Except(a, StringComparer.OrdinalIgnoreCase).Any();
|
||||
|
||||
private static void BackfillKnownPageMigrations(RbacRole r, bool hadSystemHub)
|
||||
{
|
||||
if (!string.Equals(r.Scope, PageCatalog.ScopePlatform, StringComparison.OrdinalIgnoreCase)
|
||||
&& r.Scope != PageCatalog.Wildcard) return;
|
||||
@@ -132,6 +151,25 @@ public sealed class RbacStore
|
||||
&& r.Pages.Contains("admin-cars", StringComparer.OrdinalIgnoreCase)
|
||||
&& r.Pages.Contains("admin-maps", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-setup");
|
||||
|
||||
// 日志管理、调度回放从监控配置聚合页拆出:原先有聚合页权限的角色补上新入口。
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard)
|
||||
&& r.Pages.Contains("admin-config-ops-center", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!r.Pages.Contains("admin-logs", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-logs");
|
||||
if (!r.Pages.Contains("admin-playback", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-playback");
|
||||
}
|
||||
|
||||
// 「系统与权限」拆成系统配置 / 权限与角色。
|
||||
if (!r.Pages.Contains(PageCatalog.Wildcard) && hadSystemHub)
|
||||
{
|
||||
if (!r.Pages.Contains("admin-config-system", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-config-system");
|
||||
if (!r.Pages.Contains("admin-config-auth", StringComparer.OrdinalIgnoreCase))
|
||||
r.Pages.Add("admin-config-auth");
|
||||
}
|
||||
}
|
||||
|
||||
private RbacSnapshot SeedDefault(IConfiguration config)
|
||||
@@ -159,7 +197,7 @@ public sealed class RbacStore
|
||||
{
|
||||
Id = RoleOpsId, Name = "运营人员", Description = "运营监控端默认角色:可执行运维操作、查看监控",
|
||||
Scope = PageCatalog.ScopeMonitor,
|
||||
Pages = new() { "monitor-dashboard", "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
|
||||
Pages = new() { "monitor-vehicle-hub", "monitor-map", "monitor-ops", "monitor-notes" },
|
||||
Ops = new()
|
||||
{
|
||||
"ops.car.pause", "ops.car.resume", "ops.car.gohome", "ops.car.resetSession",
|
||||
@@ -250,6 +288,20 @@ public sealed class RbacStore
|
||||
public bool CanUseScope(RbacUser user, string scope) =>
|
||||
UsableScopes(user).Contains(scope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 登录按账号角色自动选域:能进管理端则进管理端,否则进运营权限页。
|
||||
/// 不再接受客户端挑选「运营端 / 管理端」。
|
||||
/// </summary>
|
||||
public string? ResolveLoginScope(RbacUser user)
|
||||
{
|
||||
var scopes = UsableScopes(user);
|
||||
if (scopes.Contains(PageCatalog.ScopePlatform, StringComparer.OrdinalIgnoreCase))
|
||||
return PageCatalog.ScopePlatform;
|
||||
if (scopes.Contains(PageCatalog.ScopeMonitor, StringComparer.OrdinalIgnoreCase))
|
||||
return PageCatalog.ScopeMonitor;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>角色名(展示用,写入 AuthUserDto.Roles / JWT role claim)。</summary>
|
||||
public List<string> RoleNamesOf(RbacUser user)
|
||||
{
|
||||
@@ -276,7 +328,7 @@ public sealed class RbacStore
|
||||
foreach (var r in roles)
|
||||
{
|
||||
if (r.Pages.Contains(PageCatalog.Wildcard)) pages.UnionWith(scopeKeys);
|
||||
else foreach (var p in r.Pages) if (scopeKeys.Contains(p)) pages.Add(p);
|
||||
else pages.UnionWith(PageCatalog.ExpandKeysForScope(r.Pages, scope));
|
||||
|
||||
foreach (var o in r.Ops)
|
||||
{
|
||||
@@ -486,7 +538,11 @@ public sealed class RbacStore
|
||||
{
|
||||
if (pages is null) return new();
|
||||
if (pages.Contains(PageCatalog.Wildcard)) return new() { PageCatalog.Wildcard };
|
||||
return pages.Where(PageCatalog.IsValidKey).Distinct().ToList();
|
||||
return pages
|
||||
.Select(PageCatalog.NormalizeKey)
|
||||
.Where(p => p == PageCatalog.Wildcard || PageCatalog.IsValidKey(p))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeScope(string? scope) => scope switch
|
||||
|
||||
@@ -29,6 +29,7 @@ public class RbacController : ControllerBase
|
||||
new("ops.car.gohome", "车辆 · 回库"),
|
||||
new("ops.car.resetSession", "车辆 · 重置会话"),
|
||||
new("ops.car.manualCharge", "车辆 · 手动充电"),
|
||||
new("ops.car.execute", "车辆 · 地图监控动作(按管理端配置)"),
|
||||
new("ops.task.pause", "任务 · 暂停"),
|
||||
new("ops.task.cancel", "任务 · 取消"),
|
||||
new("ops.task.reassign", "任务 · 改派"),
|
||||
@@ -61,18 +62,35 @@ public class RbacController : ControllerBase
|
||||
|
||||
/// <summary>权限「字典」:页面清单 + 可选操作码 + 可选控件 + scope 选项。前端角色编辑器据此渲染勾选项。</summary>
|
||||
[HttpGet("catalog")]
|
||||
public IActionResult Catalog() => Ok(new
|
||||
public IActionResult Catalog()
|
||||
{
|
||||
pages = PageCatalog.All,
|
||||
ops = KnownOps,
|
||||
widgets = KnownWidgets,
|
||||
scopes = new[]
|
||||
var actorPages = ActorPlatformPages();
|
||||
var grantablePlatform = PageCatalog.GrantablePlatformPages(actorPages).OrderBy(x => x).ToList();
|
||||
var grantableMonitor = PageCatalog.GrantableMonitorPages(actorPages).OrderBy(x => x).ToList();
|
||||
return Ok(new
|
||||
{
|
||||
new { value = PageCatalog.ScopePlatform, label = "管理端 (Platform)" },
|
||||
new { value = PageCatalog.ScopeMonitor, label = "运营端 (RCSMonitor)" },
|
||||
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
|
||||
}
|
||||
});
|
||||
pages = PageCatalog.All,
|
||||
ops = KnownOps,
|
||||
widgets = KnownWidgets,
|
||||
scopes = new[]
|
||||
{
|
||||
new { value = PageCatalog.ScopePlatform, label = "管理权限 (Platform)" },
|
||||
new { value = PageCatalog.ScopeMonitor, label = "运营权限 (RCSMonitor)" },
|
||||
new { value = PageCatalog.Wildcard, label = "通用 (全部域)" },
|
||||
},
|
||||
// 当前登录管理员可勾选的页面(运营端由管理端已有页映射而来)。
|
||||
grantablePages = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[PageCatalog.ScopePlatform] = grantablePlatform,
|
||||
[PageCatalog.ScopeMonitor] = grantableMonitor,
|
||||
[PageCatalog.Wildcard] = grantablePlatform.Concat(grantableMonitor).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(),
|
||||
},
|
||||
platformToMonitor = PageCatalog.PlatformToMonitor
|
||||
.Select(kv => new { platform = kv.Key, monitor = kv.Value })
|
||||
.ToList(),
|
||||
monitorOnlyPages = PageCatalog.MonitorOnlyPages,
|
||||
});
|
||||
}
|
||||
|
||||
// ───────────────────────── 角色 ─────────────────────────
|
||||
|
||||
@@ -80,10 +98,12 @@ public class RbacController : ControllerBase
|
||||
public IActionResult ListRoles() => Ok(_store.ListRoles());
|
||||
|
||||
[HttpPost("roles")]
|
||||
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.CreateRole(req)));
|
||||
public IActionResult CreateRole([FromBody] SaveRoleRequest req) => Guard(() =>
|
||||
Ok(_store.CreateRole(ClampPages(req))));
|
||||
|
||||
[HttpPut("roles/{id}")]
|
||||
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() => Ok(_store.UpdateRole(id, req)));
|
||||
public IActionResult UpdateRole(string id, [FromBody] SaveRoleRequest req) => Guard(() =>
|
||||
Ok(_store.UpdateRole(id, ClampPages(req))));
|
||||
|
||||
[HttpDelete("roles/{id}")]
|
||||
public IActionResult DeleteRole(string id) => Guard(() =>
|
||||
@@ -136,4 +156,43 @@ public class RbacController : ControllerBase
|
||||
|
||||
private string? CurrentUserId() =>
|
||||
User.FindFirstValue("sub") ?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
|
||||
/// <summary>当前登录管理员在管理端的有效页面集合。</summary>
|
||||
private List<string> ActorPlatformPages()
|
||||
{
|
||||
var id = CurrentUserId();
|
||||
if (string.IsNullOrEmpty(id)) return new();
|
||||
var user = _store.FindUserById(id);
|
||||
if (user is null) return new();
|
||||
return _store.ComputeEffective(user, PageCatalog.ScopePlatform).Pages;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 保存角色时按「当前管理员可授页面」裁剪:运营端页只能选自管理端已有页的映射 + 运营专属页。
|
||||
/// 通配 <c>*</c> 仅当可授集合已覆盖该域全部页面时才保留。
|
||||
/// </summary>
|
||||
private SaveRoleRequest ClampPages(SaveRoleRequest req)
|
||||
{
|
||||
var scope = string.IsNullOrWhiteSpace(req.Scope) ? PageCatalog.ScopePlatform : req.Scope.Trim();
|
||||
var grantable = PageCatalog.GrantablePagesForRoleScope(scope, ActorPlatformPages());
|
||||
var pages = req.Pages ?? new List<string>();
|
||||
if (pages.Contains(PageCatalog.Wildcard, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var scopeKeys = scope == PageCatalog.Wildcard
|
||||
? PageCatalog.All.Select(p => p.Key).ToList()
|
||||
: PageCatalog.KeysForScope(scope).ToList();
|
||||
if (scopeKeys.All(k => grantable.Contains(k)))
|
||||
return req with { Pages = new List<string> { PageCatalog.Wildcard } };
|
||||
return req with
|
||||
{
|
||||
Pages = scopeKeys.Where(k => grantable.Contains(k)).Distinct(StringComparer.OrdinalIgnoreCase).ToList()
|
||||
};
|
||||
}
|
||||
var clamped = pages
|
||||
.Select(PageCatalog.NormalizeKey)
|
||||
.Where(p => p == PageCatalog.Wildcard || (PageCatalog.IsValidKey(p) && grantable.Contains(p)))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
return req with { Pages = clamped };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@ public static class DashboardShortcutCatalog
|
||||
["platform-config"] = "admin-map-editor",
|
||||
["mission"] = "admin-task-templates",
|
||||
["cars"] = "admin-cars",
|
||||
["auth"] = "admin-config-system-center",
|
||||
["system"] = "admin-config-system-center",
|
||||
["auth"] = "admin-config-auth",
|
||||
["system"] = "admin-config-system",
|
||||
["admin-config-system-center"] = "admin-config-auth",
|
||||
["ops"] = "admin-config-ops-center",
|
||||
["tasks"] = "admin-config-strategy",
|
||||
["tasks"] = "admin-task-templates",
|
||||
["admin-config-strategy"] = "admin-task-templates",
|
||||
};
|
||||
|
||||
private static readonly ShortcutDef[] PlatformShortcuts =
|
||||
@@ -28,6 +30,10 @@ public static class DashboardShortcutCatalog
|
||||
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
|
||||
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
|
||||
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
|
||||
new("admin-playback", "admin-playback", PageCatalog.ScopePlatform),
|
||||
new("admin-ops-log", "admin-ops-log", PageCatalog.ScopePlatform),
|
||||
new("admin-notes", "admin-notes", PageCatalog.ScopePlatform),
|
||||
new("admin-logs", "admin-logs", PageCatalog.ScopePlatform),
|
||||
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
|
||||
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
|
||||
new("admin-project-properties", "admin-project-properties", PageCatalog.ScopePlatform),
|
||||
@@ -38,17 +44,17 @@ public static class DashboardShortcutCatalog
|
||||
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
||||
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
|
||||
new("admin-data-center", "admin-data-center", PageCatalog.ScopePlatform),
|
||||
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
|
||||
new("admin-setup", "admin-setup", PageCatalog.ScopePlatform),
|
||||
new("admin-wcs-template-proto", "admin-wcs-template-proto", PageCatalog.ScopePlatform),
|
||||
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
||||
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
||||
new("admin-config-business", "admin-config-business", PageCatalog.ScopePlatform),
|
||||
new("admin-config-ops-center", "admin-config-ops-center", PageCatalog.ScopePlatform),
|
||||
new("admin-config-system-center", "admin-config-system-center", PageCatalog.ScopePlatform),
|
||||
new("admin-config-system", "admin-config-system", PageCatalog.ScopePlatform),
|
||||
new("admin-config-auth", "admin-config-auth", PageCatalog.ScopePlatform),
|
||||
];
|
||||
|
||||
private static readonly ShortcutDef[] MonitorShortcuts =
|
||||
[
|
||||
new("monitor-dashboard", "monitor-dashboard", PageCatalog.ScopeMonitor),
|
||||
new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor),
|
||||
new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor),
|
||||
new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor),
|
||||
@@ -80,16 +86,16 @@ public static class DashboardShortcutCatalog
|
||||
"admin-map-monitor",
|
||||
"admin-vehicle-hub",
|
||||
"admin-task-templates",
|
||||
"admin-config-system-center",
|
||||
"admin-config-auth",
|
||||
"admin-config-ops-center",
|
||||
"admin-config-strategy"
|
||||
"admin-config-facility"
|
||||
];
|
||||
|
||||
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
|
||||
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
|
||||
|
||||
private static readonly HashSet<string> ExcludedKeys =
|
||||
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" };
|
||||
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard" };
|
||||
|
||||
public static bool IsValidKey(string key) =>
|
||||
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
|
||||
|
||||
@@ -19,12 +19,18 @@
|
||||
"id": "role-ops",
|
||||
"name": "\u8FD0\u8425\u4EBA\u5458",
|
||||
"description": "\u8FD0\u8425\u76D1\u63A7\u7AEF\u9ED8\u8BA4\u89D2\u8272\uFF1A\u53EF\u6267\u884C\u8FD0\u7EF4\u64CD\u4F5C\u3001\u67E5\u770B\u76D1\u63A7",
|
||||
"scope": "RCSMonitor",
|
||||
"scope": "*",
|
||||
"pages": [
|
||||
"monitor-dashboard",
|
||||
"monitor-map",
|
||||
"monitor-notes",
|
||||
"admin-dashboard",
|
||||
"admin-map-monitor",
|
||||
"admin-tasks",
|
||||
"admin-alarms",
|
||||
"admin-config-warehouse",
|
||||
"monitor-ops",
|
||||
"monitor-notes"
|
||||
"admin-config-ops-center",
|
||||
"admin-logs",
|
||||
"admin-playback"
|
||||
],
|
||||
"ops": [
|
||||
"ops.car.pause",
|
||||
@@ -87,8 +93,8 @@
|
||||
"roleIds": [
|
||||
"role-ops"
|
||||
],
|
||||
"salt": "G6c5\u002BnLEjf3fEor1LgNjyg==",
|
||||
"passwordHash": "Zb1QiDzHctduFbZGKniTEy4OLJIvIOeoyLfc\u002Bo0y\u002BVg="
|
||||
"salt": "F6wxGwnf/nvdbbAKp6hYoQ==",
|
||||
"passwordHash": "\u002BFvJCZgUYReNSr4T6xIpZU7FoajVHe067JVaHf/Evb8="
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export async function listDeliveries(opts?: {
|
||||
return Array.isArray(data) ? data : []
|
||||
}
|
||||
|
||||
/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(SimpleLite 关闭时仍可读,含完整历史)。 */
|
||||
/** CDM 任务快照订阅结果:来自平台库 cdm_tasks(Simple3 关闭时仍可读,含完整历史)。 */
|
||||
export interface CdmTaskFeed {
|
||||
online: boolean
|
||||
lastSyncAt: string | null
|
||||
|
||||
@@ -9,18 +9,26 @@
|
||||
<el-empty v-else class="pg-hidden">
|
||||
<template #description>
|
||||
<p class="pg-hidden-title">当前账号无权查看此功能</p>
|
||||
<p class="pg-hidden-hint">控件:{{ widgetId }}。若应为管理员可见,请尝试顶部切换「管理员/运营」或刷新页面。</p>
|
||||
<p class="pg-hidden-hint">控件:{{ widgetId }}。请在「权限与角色」中为该账号开放对应页面后重新登录。</p>
|
||||
</template>
|
||||
</el-empty>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{ widgetId: string }>()
|
||||
const auth = useAuthStore()
|
||||
const visibility = computed(() => auth.widgetOf(props.widgetId))
|
||||
const route = useRoute()
|
||||
|
||||
/** 页面已授权则具备该页管理能力,不再被 ConfigCenter 等控件 hidden 整页挡住。 */
|
||||
const visibility = computed(() => {
|
||||
const name = typeof route.name === 'string' ? route.name : ''
|
||||
if (name && auth.hasPage(name)) return 'interactive' as const
|
||||
return auth.widgetOf(props.widgetId)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -3,13 +3,13 @@ import {
|
||||
Collection,
|
||||
Connection,
|
||||
Cpu,
|
||||
DataBoard,
|
||||
Document,
|
||||
DocumentCopy,
|
||||
EditPen,
|
||||
Files,
|
||||
Grid,
|
||||
Histogram,
|
||||
Link,
|
||||
List,
|
||||
MapLocation,
|
||||
Monitor,
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
Promotion,
|
||||
SetUp,
|
||||
Setting,
|
||||
Tickets,
|
||||
Tools,
|
||||
User,
|
||||
Van,
|
||||
@@ -43,8 +44,12 @@ export const ADMIN_MENU: NavMenuItem[] = [
|
||||
path: '/admin/operations', label: '运营管理', icon: Monitor, group: '概览',
|
||||
children: [
|
||||
{ path: '/admin/map-monitor', label: '地图监控', icon: View, key: 'admin-map-monitor', group: '概览' },
|
||||
{ path: '/admin/playback', label: '调度回放', icon: VideoCamera, key: 'admin-playback', group: '概览' },
|
||||
{ path: '/admin/tasks', label: '任务管理', icon: List, key: 'admin-tasks', group: '概览' },
|
||||
{ path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', group: '概览' }
|
||||
{ path: '/admin/alarms', label: '报警管理', icon: Bell, key: 'admin-alarms', group: '概览' },
|
||||
{ path: '/admin/ops-log', label: '运维记录', icon: Promotion, key: 'admin-ops-log', group: '概览' },
|
||||
{ path: '/admin/notes', label: '运维备注', icon: Notebook, key: 'admin-notes', group: '概览' },
|
||||
{ path: '/admin/logs', label: '日志管理', icon: Tickets, key: 'admin-logs', group: '概览' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -75,23 +80,21 @@ export const ADMIN_MENU: NavMenuItem[] = [
|
||||
{
|
||||
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
|
||||
children: [
|
||||
{ path: '/admin/config/strategy', label: '调度策略', icon: SetUp, key: 'admin-config-strategy', group: '平台配置中心' },
|
||||
{ path: '/admin/config/vehicle-hub', label: '车辆运维', icon: Van, key: 'admin-vehicle-hub', group: '平台配置中心' },
|
||||
{ path: '/admin/config/facility', label: '设备接入', icon: OfficeBuilding, key: 'admin-config-facility', group: '平台配置中心' },
|
||||
{ path: '/admin/config/warehouse', label: '仓储管理', icon: OfficeBuilding, key: 'admin-config-warehouse', group: '平台配置中心' },
|
||||
{ path: '/admin/config/business', label: '业务与集成', icon: Link, key: 'admin-config-business', group: '平台配置中心' },
|
||||
{ path: '/admin/config/ops-center', label: '运维与回放', icon: VideoCamera, key: 'admin-config-ops-center', group: '平台配置中心' },
|
||||
{ path: '/admin/config/system-center', label: '系统与权限', icon: User, key: 'admin-config-system-center', group: '平台配置中心' }
|
||||
{ path: '/admin/config/ops-center', label: '监控配置', icon: DataBoard, key: 'admin-config-ops-center', group: '平台配置中心' },
|
||||
{ path: '/admin/config/system', label: '系统配置', icon: Setting, key: 'admin-config-system', group: '平台配置中心' },
|
||||
{ path: '/admin/config/auth', label: '权限与角色', icon: User, key: 'admin-config-auth', group: '平台配置中心' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export const MONITOR_MENU: NavMenuItem[] = [
|
||||
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard', group: '运营监控' },
|
||||
{ path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub', group: '运营监控' },
|
||||
{ path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map', group: '运营监控' },
|
||||
{ path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops', group: '运营监控' },
|
||||
{ path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' }
|
||||
{ path: '/monitor/ops', label: '运维记录', icon: Promotion, key: 'monitor-ops', group: '运营监控' },
|
||||
{ path: '/monitor/notes', label: '运维备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' }
|
||||
]
|
||||
|
||||
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
|
||||
|
||||
@@ -20,17 +20,19 @@ export interface QuickEntryDef {
|
||||
}
|
||||
|
||||
/** 当前页即总览,不作为快捷入口候选 */
|
||||
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard'])
|
||||
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard'])
|
||||
|
||||
/** 旧版别名 key -> 菜单 key */
|
||||
const LEGACY_KEY_ALIASES: Record<string, string> = {
|
||||
'platform-config': 'admin-map-editor',
|
||||
mission: 'admin-task-templates',
|
||||
cars: 'admin-cars',
|
||||
auth: 'admin-config-system-center',
|
||||
system: 'admin-config-system-center',
|
||||
auth: 'admin-config-auth',
|
||||
system: 'admin-config-system',
|
||||
'admin-config-system-center': 'admin-config-auth',
|
||||
ops: 'admin-config-ops-center',
|
||||
tasks: 'admin-config-strategy'
|
||||
tasks: 'admin-task-templates',
|
||||
'admin-config-strategy': 'admin-task-templates'
|
||||
}
|
||||
|
||||
/** Platform 域下固定保留、不可删除的快捷入口 */
|
||||
@@ -46,9 +48,9 @@ export const MANDATORY_PLATFORM_QUICK_KEYS = [
|
||||
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐 */
|
||||
export const DEFAULT_PLATFORM_QUICK_KEYS = [
|
||||
...MANDATORY_PLATFORM_QUICK_KEYS,
|
||||
'admin-config-system-center',
|
||||
'admin-config-auth',
|
||||
'admin-config-ops-center',
|
||||
'admin-config-strategy'
|
||||
'admin-config-facility'
|
||||
] as const
|
||||
|
||||
export const DEFAULT_MONITOR_QUICK_KEYS = [
|
||||
|
||||
@@ -17,9 +17,6 @@ import type {
|
||||
} from '@/types/config'
|
||||
|
||||
export const DEFAULT_SYSTEM: SystemConfig = {
|
||||
dispatchLoopHz: 50,
|
||||
log: { level: 'info', rollDays: 7, maxSizeMB: 256 },
|
||||
security: { jwtExpireMin: 1440, enableSwagger: false, corsWhitelist: ['http://localhost:5173', 'https://*.intra'] }
|
||||
}
|
||||
|
||||
export const DEFAULT_INTEGRATIONS: ExternalIntegrations = {
|
||||
|
||||
@@ -13,8 +13,12 @@ const PAGES: PageDef[] = [
|
||||
{ key: 'admin-dashboard', label: '总览', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-setup', label: '初始配置', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-map-monitor', label: '地图监控', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-playback', label: '调度回放', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-tasks', label: '任务管理', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-alarms', label: '报警管理', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-ops-log', label: '运维记录', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-notes', label: '运维备注', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-logs', label: '日志管理', group: '概览', scope: 'Platform' },
|
||||
{ key: 'admin-maps', label: '地图管理', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-map-editor', label: '地图编辑', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-project-properties', label: '项目属性', group: '设计与编排', scope: 'Platform' },
|
||||
@@ -25,18 +29,16 @@ const PAGES: PageDef[] = [
|
||||
{ key: 'admin-task-templates', label: '任务编排', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-wcs-template-proto', label: 'WCS模板原型', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-simple-fields', label: '字段管理', group: '设计与编排', scope: 'Platform' },
|
||||
{ key: 'admin-config-strategy', label: '调度策略', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-vehicle-hub', label: '车辆运维', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-facility', label: '设备接入', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-warehouse', label: '库位管理', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-business', label: '业务与集成', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-ops-center', label: '运维与回放', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-system-center', label: '系统与权限', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'monitor-dashboard', label: '运营总览', group: '运营监控', scope: 'RCSMonitor' },
|
||||
{ key: 'admin-config-ops-center', label: '监控配置', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-system', label: '系统配置', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'admin-config-auth', label: '权限与角色', group: '平台配置中心', scope: 'Platform' },
|
||||
{ key: 'monitor-vehicle-hub', label: '车辆运维', group: '运营监控', scope: 'RCSMonitor' },
|
||||
{ key: 'monitor-map', label: '地图监控', group: '运营监控', scope: 'RCSMonitor' },
|
||||
{ key: 'monitor-ops', label: '运维操作', group: '运营监控', scope: 'RCSMonitor' },
|
||||
{ key: 'monitor-notes', label: '运营备注', group: '运营监控', scope: 'RCSMonitor' }
|
||||
{ key: 'monitor-ops', label: '运维记录', group: '运营监控', scope: 'RCSMonitor' },
|
||||
{ key: 'monitor-notes', label: '运维备注', group: '运营监控', scope: 'RCSMonitor' }
|
||||
]
|
||||
|
||||
const OPS: OpDef[] = [
|
||||
@@ -80,7 +82,7 @@ function seed(): RbacState {
|
||||
{
|
||||
id: 'role-ops', name: '运营人员', description: '运营监控端默认角色:可执行运维操作、查看监控',
|
||||
scope: 'RCSMonitor',
|
||||
pages: ['monitor-dashboard', 'monitor-map', 'monitor-ops', 'monitor-notes', 'monitor-vehicle-hub'],
|
||||
pages: ['monitor-map', 'monitor-ops', 'monitor-notes', 'monitor-vehicle-hub'],
|
||||
ops: [
|
||||
'ops.car.pause', 'ops.car.resume', 'ops.car.gohome', 'ops.car.resetSession',
|
||||
'ops.car.manualCharge', 'ops.task.pause', 'ops.task.cancel', 'ops.task.reassign',
|
||||
@@ -107,19 +109,26 @@ function seed(): RbacState {
|
||||
function load(): RbacState {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (raw) return JSON.parse(raw) as RbacState
|
||||
if (raw) {
|
||||
const s = JSON.parse(raw) as RbacState
|
||||
for (const r of s.roles ?? []) {
|
||||
r.pages = sanitizePages(r.pages ?? [])
|
||||
}
|
||||
persist(s)
|
||||
return s
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
const s = seed()
|
||||
persist(s)
|
||||
return s
|
||||
}
|
||||
|
||||
let state: RbacState = load()
|
||||
|
||||
function persist(s: RbacState = state) {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(s)) } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
let state: RbacState = load()
|
||||
|
||||
function scopesOf(roleIds: string[]): string[] {
|
||||
const set = new Set<string>()
|
||||
for (const id of roleIds) {
|
||||
@@ -144,35 +153,77 @@ function sanitizePages(pages: string[]): string[] {
|
||||
'admin-config-vehicle': 'admin-vehicle-hub',
|
||||
'admin-config-fleet': 'admin-vehicle-hub',
|
||||
// 会话 16:配置中心收敛为 6 个聚合页,旧页面 Key 迁移到对应聚合页 Key。
|
||||
'admin-config-routing': 'admin-config-strategy',
|
||||
'admin-config-task': 'admin-config-strategy',
|
||||
'admin-config-traffic': 'admin-config-strategy',
|
||||
'admin-config-charge': 'admin-config-strategy',
|
||||
'admin-config-device': 'admin-config-facility',
|
||||
'admin-config-location': 'admin-config-warehouse',
|
||||
'admin-config-integrations': 'admin-config-business',
|
||||
'admin-config-scenario': 'admin-config-business',
|
||||
'admin-config-widget': 'admin-config-business',
|
||||
'admin-playback': 'admin-config-ops-center',
|
||||
'admin-config-ops': 'admin-config-ops-center',
|
||||
'admin-config-logs': 'admin-config-ops-center',
|
||||
'admin-config-logs': 'admin-logs',
|
||||
'admin-config-map-monitor': 'admin-config-ops-center',
|
||||
'admin-config-system': 'admin-config-system-center',
|
||||
'admin-config-auth': 'admin-config-system-center'
|
||||
'admin-config-system-center': 'admin-config-system',
|
||||
'monitor-dashboard': 'monitor-map'
|
||||
}
|
||||
return [...new Set(pages.map((p) => legacy[p] ?? p).filter((p) => valid.has(p)))]
|
||||
const hadSystemHub = pages.includes('admin-config-system-center')
|
||||
const mapped = [...new Set(pages.map((p) => legacy[p] ?? p).filter((p) => valid.has(p)))]
|
||||
if (
|
||||
mapped.includes('admin-config-ops-center')
|
||||
&& !mapped.includes('*')
|
||||
) {
|
||||
if (!mapped.includes('admin-logs')) mapped.push('admin-logs')
|
||||
if (!mapped.includes('admin-playback')) mapped.push('admin-playback')
|
||||
}
|
||||
if (hadSystemHub && !mapped.includes('*')) {
|
||||
if (!mapped.includes('admin-config-system')) mapped.push('admin-config-system')
|
||||
if (!mapped.includes('admin-config-auth')) mapped.push('admin-config-auth')
|
||||
}
|
||||
return mapped
|
||||
}
|
||||
|
||||
const PLATFORM_TO_MONITOR: Record<string, string> = {
|
||||
'admin-map-monitor': 'monitor-map',
|
||||
'admin-vehicle-hub': 'monitor-vehicle-hub',
|
||||
'admin-ops-log': 'monitor-ops',
|
||||
'admin-notes': 'monitor-notes'
|
||||
}
|
||||
const MONITOR_ONLY = ['monitor-ops', 'monitor-notes']
|
||||
|
||||
function grantableFromPlatform(platformPages: string[]): { Platform: string[]; RCSMonitor: string[]; '*': string[] } {
|
||||
const platformKeys = PAGES.filter((p) => p.scope === 'Platform').map((p) => p.key)
|
||||
const monitorKeys = PAGES.filter((p) => p.scope === 'RCSMonitor').map((p) => p.key)
|
||||
const hasAll = platformPages.includes('*') || platformKeys.every((k) => platformPages.includes(k))
|
||||
const platform = hasAll ? platformKeys : platformPages.filter((k) => platformKeys.includes(k))
|
||||
const monitor = new Set(MONITOR_ONLY)
|
||||
if (hasAll) monitorKeys.forEach((k) => monitor.add(k))
|
||||
else {
|
||||
for (const p of platform) {
|
||||
const m = PLATFORM_TO_MONITOR[p]
|
||||
if (m) monitor.add(m)
|
||||
}
|
||||
}
|
||||
const mon = [...monitor]
|
||||
return { Platform: platform, RCSMonitor: mon, '*': [...new Set([...platform, ...mon])] }
|
||||
}
|
||||
|
||||
export function mockRbacCatalog(): RbacCatalog {
|
||||
const me = (() => {
|
||||
try {
|
||||
const raw = localStorage.getItem('simple.auth.user')
|
||||
const u = raw ? JSON.parse(raw) as { username?: string } : null
|
||||
return u?.username?.trim() || 'admin'
|
||||
} catch { return 'admin' }
|
||||
})()
|
||||
const platformPages = mockComputeAllowedPages(me, 'Platform')
|
||||
const grantable = grantableFromPlatform(platformPages)
|
||||
return {
|
||||
pages: PAGES,
|
||||
ops: OPS,
|
||||
widgets: WIDGETS,
|
||||
scopes: [
|
||||
{ value: 'Platform', label: '管理端 (Platform)' },
|
||||
{ value: 'RCSMonitor', label: '运营端 (RCSMonitor)' },
|
||||
{ value: 'Platform', label: '管理权限 (Platform)' },
|
||||
{ value: 'RCSMonitor', label: '运营权限 (RCSMonitor)' },
|
||||
{ value: '*', label: '通用 (全部域)' }
|
||||
]
|
||||
],
|
||||
grantablePages: grantable,
|
||||
platformToMonitor: Object.entries(PLATFORM_TO_MONITOR).map(([platform, monitor]) => ({ platform, monitor })),
|
||||
monitorOnlyPages: MONITOR_ONLY
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,16 +318,34 @@ export async function mockRbacDeleteUser(id: string): Promise<void> {
|
||||
persist()
|
||||
}
|
||||
|
||||
/** 登录按账号角色自动选域:能进管理则管理,否则运营。未知用户默认管理。 */
|
||||
export function mockResolveLoginScope(username: string): 'Platform' | 'RCSMonitor' {
|
||||
const u = state.users.find((x) => x.username.toLowerCase() === username.toLowerCase())
|
||||
if (!u) return 'Platform'
|
||||
const scopes = scopesOf(u.roleIds)
|
||||
if (scopes.includes('Platform')) return 'Platform'
|
||||
if (scopes.includes('RCSMonitor')) return 'RCSMonitor'
|
||||
return 'Platform'
|
||||
}
|
||||
|
||||
/** 供 mockLogin 计算 allowedPages:按 username + scope 取角色 pages 并集;未知用户给该 scope 全部页面。 */
|
||||
export function mockComputeAllowedPages(username: string, scope: 'Platform' | 'RCSMonitor'): string[] {
|
||||
const scopeKeys = PAGES.filter((p) => p.scope === scope).map((p) => p.key)
|
||||
const u = state.users.find((x) => x.username.toLowerCase() === username.toLowerCase())
|
||||
if (!u) return scopeKeys
|
||||
const roles = state.roles.filter((r) => u.roleIds.includes(r.id) && (r.scope === '*' || r.scope === scope))
|
||||
const reverse: Record<string, string> = {}
|
||||
for (const [platform, monitor] of Object.entries(PLATFORM_TO_MONITOR)) reverse[monitor] = platform
|
||||
const set = new Set<string>()
|
||||
for (const r of roles) {
|
||||
if (r.pages.includes('*')) scopeKeys.forEach((k) => set.add(k))
|
||||
else r.pages.filter((p) => scopeKeys.includes(p)).forEach((k) => set.add(k))
|
||||
else {
|
||||
for (const p of r.pages) {
|
||||
if (scopeKeys.includes(p)) set.add(p)
|
||||
const twin = scope === 'Platform' ? reverse[p] : PLATFORM_TO_MONITOR[p]
|
||||
if (twin && scopeKeys.includes(twin)) set.add(twin)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { TRACKS } from './data/tracks'
|
||||
import { CARS } from './data/cars'
|
||||
import { MISSIONS } from './data/missions'
|
||||
import { CONFIG_DEFAULTS } from './data/configs'
|
||||
import { mockComputeAllowedPages } from './rbac'
|
||||
import { mockComputeAllowedPages, mockResolveLoginScope } from './rbac'
|
||||
|
||||
const PLATFORM_OPS = ['*']
|
||||
|
||||
@@ -57,21 +57,21 @@ function buildPerm(scope: 'Platform' | 'RCSMonitor', username: string): Effectiv
|
||||
export async function mockLogin(req: LoginRequest): Promise<LoginResponse> {
|
||||
await delay(150)
|
||||
if (!req.username) throw new Error('用户名不能为空')
|
||||
const token = `mock-jwt.${req.scope}.${req.username}.${Date.now()}`
|
||||
// mock 服务器无法真正拉起 SimpleLite,只把 LaunchMode 翻译为 RunMode 让前端 UI 自洽。
|
||||
// - 历史前端不传 launchMode → 退到 DesktopAndWeb → runMode=WebEnabled(与改造前一致)。
|
||||
// - launchMode=WebOnly → runMode=WebOnly。
|
||||
const runMode: LoginResponse['runMode'] = req.launchMode === 'WebOnly' ? 'WebOnly' : 'WebEnabled'
|
||||
const scope = mockResolveLoginScope(req.username)
|
||||
const token = `mock-jwt.${scope}.${req.username}.${Date.now()}`
|
||||
// mock 无法真正拉起内核;Simple3 仅 Web,默认 WebOnly。
|
||||
const runMode: LoginResponse['runMode'] =
|
||||
req.launchMode === 'DesktopAndWeb' ? 'WebEnabled' : 'WebOnly'
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
id: `u-${req.username}`,
|
||||
username: req.username,
|
||||
displayName: req.username === 'admin' ? '系统管理员' : (req.username === 'ops' ? '运营人员' : req.username),
|
||||
roles: req.scope === 'Platform' ? ['role-admin'] : ['role-ops']
|
||||
roles: scope === 'Platform' ? ['role-admin'] : ['role-ops']
|
||||
},
|
||||
scope: req.scope,
|
||||
effectivePermissions: buildPerm(req.scope, req.username),
|
||||
scope,
|
||||
effectivePermissions: buildPerm(scope, req.username),
|
||||
runMode
|
||||
}
|
||||
}
|
||||
@@ -135,18 +135,42 @@ export async function mockPutConfig<T>(section: ConfigSection, payload: T): Prom
|
||||
return next
|
||||
}
|
||||
|
||||
function currentMockUsername(): string {
|
||||
try {
|
||||
const raw = localStorage.getItem('simple.auth.user')
|
||||
const u = raw ? JSON.parse(raw) as { username?: string } : null
|
||||
return u?.username?.trim() || 'mock-user'
|
||||
} catch {
|
||||
return 'mock-user'
|
||||
}
|
||||
}
|
||||
|
||||
const audits: OpsAuditEntry[] = [
|
||||
{ id: 'A001', ts: nowIso(), user: 'ops', scope: 'RCSMonitor', opCode: 'ops.car.pause', target: 'C01', result: 'ok' },
|
||||
{ id: 'A002', ts: nowIso(), user: 'ops', scope: 'RCSMonitor', opCode: 'ops.task.cancel', target: 'M03', result: 'ok' }
|
||||
{ id: 'A002', ts: nowIso(), user: 'ops', scope: 'RCSMonitor', opCode: 'ops.task.cancel', target: 'M03', result: 'ok' },
|
||||
{ id: 'A003', ts: nowIso(), user: 'admin', scope: 'Platform', opCode: 'ops.car.resetSession', target: 'C02', result: 'failed', message: '示例:他人记录,运营账号不可见' }
|
||||
]
|
||||
|
||||
export async function mockOpsExecute(req: { opCode: string; targetId: string; reason?: string; idempotencyKey?: string }) {
|
||||
await delay(120)
|
||||
const id = `A${String(audits.length + 1).padStart(3, '0')}`
|
||||
audits.unshift({ id, ts: nowIso(), user: 'mock-user', scope: 'mock', opCode: req.opCode, target: req.targetId, result: 'ok', message: req.reason })
|
||||
audits.unshift({
|
||||
id,
|
||||
ts: nowIso(),
|
||||
user: currentMockUsername(),
|
||||
scope: 'mock',
|
||||
opCode: req.opCode,
|
||||
target: req.targetId,
|
||||
result: 'ok',
|
||||
message: req.reason
|
||||
})
|
||||
return { ok: true, auditId: id }
|
||||
}
|
||||
|
||||
export async function mockOpsAudits(): Promise<OpsAuditEntry[]> { await delay(60); return [...audits] }
|
||||
export async function mockOpsAudits(): Promise<OpsAuditEntry[]> {
|
||||
await delay(60)
|
||||
const me = currentMockUsername().toLowerCase()
|
||||
return audits.filter((a) => a.user.toLowerCase() === me)
|
||||
}
|
||||
|
||||
function delay(ms: number) { return new Promise((r) => setTimeout(r, ms)) }
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { isWizardSetupActive, WIZARD_SETUP_ROUTES } from '@/utils/wizardSetup'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
@@ -33,6 +34,10 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: 'map-monitor', name: 'admin-map-monitor', component: () => import('@/views/admin/MapMonitorView.vue'), meta: { title: '地图监控' } },
|
||||
{ path: 'tasks', name: 'admin-tasks', component: () => import('@/views/admin/TaskManagementView.vue'), meta: { title: '任务管理' } },
|
||||
{ path: 'alarms', name: 'admin-alarms', component: () => import('@/views/admin/AlarmManagementView.vue'), meta: { title: '报警管理' } },
|
||||
{ path: 'ops-log', name: 'admin-ops-log', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维记录' } },
|
||||
{ path: 'notes', name: 'admin-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运维备注' } },
|
||||
{ path: 'logs', name: 'admin-logs', component: () => import('@/views/admin/LogManagementView.vue'), meta: { title: '日志管理' } },
|
||||
{ path: 'playback', name: 'admin-playback', component: () => import('@/views/admin/PlaybackView.vue'), meta: { title: '调度回放' } },
|
||||
{ path: 'maps', name: 'admin-maps', component: () => import('@/views/admin/MapManagementView.vue'), meta: { title: '地图管理' } },
|
||||
{ path: 'map-editor', name: 'admin-map-editor', component: () => import('@/views/admin/MapEditorView.vue'), meta: { title: '地图编辑' } },
|
||||
{ path: 'tracks', name: 'admin-tracks', component: () => import('@/views/admin/TrackTableView.vue'), meta: { title: '场景管理' } },
|
||||
@@ -51,33 +56,33 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { title: '数据中心' }
|
||||
},
|
||||
// ── 平台配置中心:聚合页 + 独立业务页(page key = route.name,对齐后端 PageCatalog)。 ──
|
||||
{ path: 'config/strategy', name: 'admin-config-strategy', component: () => import('@/views/admin/config/StrategyConfigView.vue'), meta: { title: '调度策略' } },
|
||||
{ path: 'config/vehicle-hub', name: 'admin-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
||||
{ path: 'config/facility', name: 'admin-config-facility', component: () => import('@/views/admin/config/FacilityConfigView.vue'), meta: { title: '设备接入' } },
|
||||
{ path: 'config/warehouse', name: 'admin-config-warehouse', component: () => import('@/views/admin/WarehouseManagementView.vue'), meta: { title: '仓储管理' } },
|
||||
{ path: 'config/business', name: 'admin-config-business', component: () => import('@/views/admin/config/BusinessConfigView.vue'), meta: { title: '业务与集成' } },
|
||||
{ path: 'config/ops-center', name: 'admin-config-ops-center', component: () => import('@/views/admin/config/OpsCenterView.vue'), meta: { title: '运维与回放' } },
|
||||
{ path: 'config/system-center', name: 'admin-config-system-center', component: () => import('@/views/admin/config/SystemCenterView.vue'), meta: { title: '系统与权限' } },
|
||||
{ path: 'config/ops-center', name: 'admin-config-ops-center', component: () => import('@/views/admin/config/OpsCenterView.vue'), meta: { title: '监控配置' } },
|
||||
{ path: 'config/system', name: 'admin-config-system', component: () => import('@/views/admin/config/SystemConfigView.vue'), meta: { title: '系统配置' } },
|
||||
{ path: 'config/auth', name: 'admin-config-auth', component: () => import('@/views/admin/config/AuthRoleView.vue'), meta: { title: '权限与角色' } },
|
||||
|
||||
// ── 旧路径深链接兼容:redirect 到聚合页对应 tab(无 name → 不计入受权限管理的页面)。 ──
|
||||
{ path: 'playback', redirect: { path: '/admin/config/ops-center', query: { tab: 'playback' } } },
|
||||
{ path: 'config/ops-center/playback', redirect: '/admin/playback' },
|
||||
{ path: 'vehicle-hub', redirect: '/admin/config/vehicle-hub' },
|
||||
{ path: 'config/vehicle', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
|
||||
{ path: 'config/fleet', redirect: { path: '/admin/config/vehicle-hub', query: { tab: 'ota' } } },
|
||||
{ path: 'config/routing', redirect: { path: '/admin/config/strategy', query: { tab: 'routing' } } },
|
||||
{ path: 'config/task', redirect: { path: '/admin/config/strategy', query: { tab: 'task' } } },
|
||||
{ path: 'config/traffic', redirect: { path: '/admin/config/strategy', query: { tab: 'traffic' } } },
|
||||
{ path: 'config/charge', redirect: { path: '/admin/config/strategy', query: { tab: 'charge' } } },
|
||||
{ path: 'config/routing', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/task', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/traffic', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/charge', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/strategy', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/device', redirect: '/admin/config/facility' },
|
||||
{ path: 'config/location', redirect: '/admin/config/warehouse' },
|
||||
{ path: 'config/integrations', redirect: { path: '/admin/config/business', query: { tab: 'integrations' } } },
|
||||
{ path: 'config/scenario', redirect: { path: '/admin/config/business', query: { tab: 'scenario' } } },
|
||||
{ path: 'config/widget', redirect: { path: '/admin/config/business', query: { tab: 'widget' } } },
|
||||
{ path: 'config/business', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/integrations', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/scenario', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/widget', redirect: '/admin/dashboard' },
|
||||
{ path: 'config/ops', redirect: { path: '/admin/config/ops-center', query: { tab: 'ops' } } },
|
||||
{ path: 'config/logs', redirect: { path: '/admin/config/ops-center', query: { tab: 'logs' } } },
|
||||
{ path: 'config/logs', redirect: '/admin/logs' },
|
||||
{ path: 'config/map-monitor', redirect: { path: '/admin/config/ops-center', query: { tab: 'map-monitor' } } },
|
||||
{ path: 'config/system', redirect: { path: '/admin/config/system-center', query: { tab: 'system' } } },
|
||||
{ path: 'config/auth', redirect: { path: '/admin/config/system-center', query: { tab: 'auth' } } }
|
||||
{ path: 'config/system-center', redirect: (to) => (to.query.tab === 'auth' ? '/admin/config/auth' : '/admin/config/system') },
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -86,11 +91,11 @@ const routes: RouteRecordRaw[] = [
|
||||
meta: { scope: 'RCSMonitor' },
|
||||
redirect: '/monitor/map',
|
||||
children: [
|
||||
{ path: 'dashboard', name: 'monitor-dashboard', component: () => import('@/views/monitor/MonitorDashboardView.vue'), meta: { title: '运营总览' } },
|
||||
{ path: 'dashboard', redirect: '/monitor/map' },
|
||||
{ path: 'vehicle-hub', name: 'monitor-vehicle-hub', component: () => import('@/views/shared/VehicleHubView.vue'), meta: { title: '车辆运维' } },
|
||||
{ path: 'map', name: 'monitor-map', component: () => import('@/views/monitor/MonitorMapView.vue'), meta: { title: '地图监控' } },
|
||||
{ path: 'ops', name: 'monitor-ops', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维操作' } },
|
||||
{ path: 'notes', name: 'monitor-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运营备注' } }
|
||||
{ path: 'ops', name: 'monitor-ops', component: () => import('@/views/monitor/OpsActionPanelView.vue'), meta: { title: '运维记录' } },
|
||||
{ path: 'notes', name: 'monitor-notes', component: () => import('@/views/monitor/AnnotationView.vue'), meta: { title: '运维备注' } }
|
||||
]
|
||||
},
|
||||
{ path: '/', redirect: '/login' },
|
||||
@@ -162,27 +167,29 @@ router.beforeEach(async (to) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 部署配置向导:首次部署(deployment.Configured=false)时,强制先完成平台选型再进入业务页。
|
||||
// 已在 /wizard 则放行,避免自跳死循环;保存成功后 store.markWizardDone() 解除拦截。
|
||||
// 首次部署:未完成向导时默认拦在 /wizard。
|
||||
// 选型完成后的「进入配置」允许跳到车辆 / 地图 / 设备等白名单页(inSetup=true)。
|
||||
if (auth.needsWizard && to.name !== 'wizard') {
|
||||
return { name: 'wizard' }
|
||||
const dest = typeof to.name === 'string' ? to.name : ''
|
||||
if (!(isWizardSetupActive() && WIZARD_SETUP_ROUTES.has(dest))) {
|
||||
return { name: 'wizard' }
|
||||
}
|
||||
}
|
||||
|
||||
// 会话 45 AR-6:switchScope 改为后端发起 ——
|
||||
// 必须 await 完成后再放行,否则页面用旧 scope 的 perms 渲染一帧后才被纠正。
|
||||
// 失败(如 ops 账号尝试切 Platform 被 403)则维持原 scope,路由仍放行让用户看到 readonly UI。
|
||||
// 账号角色决定工作区:管理账号走 /admin,运营账号走 /monitor。
|
||||
// 不再自动 switchScope 切到另一端——配置的运营页只在运营权限账号登录后可见。
|
||||
const needScope: 'Platform' | 'RCSMonitor' | null =
|
||||
to.path.startsWith('/admin')
|
||||
? 'Platform'
|
||||
: to.path.startsWith('/monitor') ? 'RCSMonitor'
|
||||
: null
|
||||
if (needScope && auth.scope !== needScope) {
|
||||
try {
|
||||
await auth.switchScope(needScope)
|
||||
} catch (_) {
|
||||
// 偶发失败(网络/超时)重试一次:避免带着「另一个域」的 effectivePermissions 渲染当前页,
|
||||
// 否则配置页会被 PermissionGuard 误判为隐藏 → 整页空白(刷新后重新 switch 才恢复)。
|
||||
try { await auth.switchScope(needScope) } catch (_2) { /* 仍失败:维持原 scope,UI 多为 readonly */ }
|
||||
const list = auth.scope === 'RCSMonitor' ? MONITOR_PAGES : ADMIN_PAGES
|
||||
const fallback = list.find((p) => auth.hasPage(p.name))?.path
|
||||
?? (auth.scope === 'RCSMonitor' ? '/monitor/map' : '/admin/dashboard')
|
||||
if (fallback !== to.path) {
|
||||
ElMessage.warning('当前账号不能进入该区域,已回到可访问页面')
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,18 @@ const SCOPE_KEY = 'simple.auth.scope'
|
||||
const RUN_MODE_KEY = 'simple.auth.runMode'
|
||||
const PERM_KEY = 'simple.auth.perm'
|
||||
|
||||
/** 管理页 ↔ 运营页:权限勾选任一端,菜单都认。 */
|
||||
const PAGE_TWINS: Record<string, string> = {
|
||||
'admin-ops-log': 'monitor-ops',
|
||||
'admin-notes': 'monitor-notes',
|
||||
'admin-map-monitor': 'monitor-map',
|
||||
'admin-vehicle-hub': 'monitor-vehicle-hub',
|
||||
'monitor-ops': 'admin-ops-log',
|
||||
'monitor-notes': 'admin-notes',
|
||||
'monitor-map': 'admin-map-monitor',
|
||||
'monitor-vehicle-hub': 'admin-vehicle-hub'
|
||||
}
|
||||
|
||||
// 会话 45 HC-2:每个字段独立 try/catch,避免一个 corrupt 字段把整个 state 干净化。
|
||||
function safeJsonParse<T>(key: string): T | null {
|
||||
try {
|
||||
@@ -96,14 +108,16 @@ export const useAuthStore = defineStore('auth', {
|
||||
return ops.includes('*') ? 'interactive' : 'readonly'
|
||||
},
|
||||
/**
|
||||
* 当前用户在当前 scope 下是否可访问指定页面(route.name)。
|
||||
* '*' 视为全部页面(兜底兼容)。菜单过滤与路由守卫共用此判断。
|
||||
* 当前用户是否可访问指定页面(route.name)。
|
||||
* '*' 视为全部页面。管理页与对应运营页互认(勾了 monitor-ops 也能进 admin-ops-log)。
|
||||
*/
|
||||
hasPage:
|
||||
(s) =>
|
||||
(key: string): boolean => {
|
||||
const pages = s.effectivePermissions?.allowedPages ?? []
|
||||
return pages.includes('*') || pages.includes(key)
|
||||
if (pages.includes('*') || pages.includes(key)) return true
|
||||
const twin = PAGE_TWINS[key]
|
||||
return !!twin && pages.includes(twin)
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
@@ -142,6 +156,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
try {
|
||||
const me = await apiGetMe()
|
||||
if (me.token) {
|
||||
this.token = me.token
|
||||
localStorage.setItem(TOKEN_KEY, me.token)
|
||||
}
|
||||
this.user = me.user
|
||||
this.scope = me.scope
|
||||
this.runMode = me.runMode
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WidgetGrant } from './auth'
|
||||
|
||||
/** 角色归属域:Platform(管理端)/ RCSMonitor(运营端)/ *(通用,对两域生效)。 */
|
||||
/** 角色归属域:Platform(管理权限)/ RCSMonitor(运营权限)/ *(通用,对两域生效)。 */
|
||||
export type RbacScope = 'Platform' | 'RCSMonitor' | '*'
|
||||
|
||||
/** 角色:一组「页面 + 操作码 + 控件可见性」授权。 */
|
||||
@@ -57,6 +57,14 @@ export interface RbacCatalog {
|
||||
ops: OpDef[]
|
||||
widgets: WidgetDef[]
|
||||
scopes: ScopeOption[]
|
||||
/**
|
||||
* 当前登录管理员可勾选的页面 Key(按角色归属域)。
|
||||
* 运营权限(RCSMonitor)由管理已有页映射而来,另含运维记录 / 运维备注。
|
||||
*/
|
||||
grantablePages?: Partial<Record<RbacScope, string[]>>
|
||||
/** 管理页 → 运营对应页(展示说明用)。 */
|
||||
platformToMonitor?: Array<{ platform: string; monitor: string }>
|
||||
monitorOnlyPages?: string[]
|
||||
}
|
||||
|
||||
export interface SaveRolePayload {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<el-card shadow="never" class="playback-card">
|
||||
<div class="playback-page">
|
||||
<el-card shadow="never" class="playback-card">
|
||||
<template #header>
|
||||
<div class="pb-header">
|
||||
<div class="pb-title">
|
||||
@@ -69,6 +70,7 @@
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -143,7 +145,7 @@ async function load() {
|
||||
|
||||
function onView(row: ToolbarRecordingEntry) {
|
||||
// 跳转地图监控并通过 ?playback= 触发回放;地图监控页在 webVRender iframe 就绪后调用
|
||||
// workspaceToolbarApi.startPlayback,SimpleLite 随即在该 iframe 终端弹出回放控制条。
|
||||
// workspaceToolbarApi.startPlayback,Simple3 随即在该 iframe 终端弹出回放控制条。
|
||||
router.push({ path: '/admin/map-monitor', query: { playback: row.fileName } })
|
||||
}
|
||||
|
||||
@@ -180,8 +182,16 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playback-card {
|
||||
.playback-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.playback-card {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.pb-header {
|
||||
display: flex;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<template>
|
||||
<div class="rbac-page">
|
||||
<el-card shadow="never" class="rbac-card">
|
||||
<template #header>
|
||||
<div class="rbac-header">
|
||||
<div class="rbac-title-wrap">
|
||||
<span class="rbac-title">权限与角色管理</span>
|
||||
<span class="rbac-desc">为角色分配可访问页面、操作与控件,并为用户指派角色(标准 RBAC)。</span>
|
||||
<span class="rbac-desc">为角色分配可访问页面、操作与控件。管理角色勾选「运维记录 / 运维备注」后,管理账号侧栏会出现这两页;运营角色勾选后,运营账号登录可见。不要到「运营维护」里配页面。</span>
|
||||
</div>
|
||||
<el-button :icon="Refresh" :loading="loading" @click="reloadAll">刷新</el-button>
|
||||
</div>
|
||||
@@ -145,9 +146,21 @@
|
||||
</el-form-item>
|
||||
|
||||
<el-divider content-position="left">可访问页面</el-divider>
|
||||
<p class="page-acl-hint">
|
||||
管理角色勾选的页面会出现在管理账号侧栏(含运维记录、运维备注)。运营角色勾选的页面只有运营权限账号登录后可见。
|
||||
运营可选页来自你当前已有的管理页映射(地图监控 → 运营地图;车辆运维 / 运维记录 / 运维备注同理)。管理角色只能勾你自己已有的页,不能超权授权。
|
||||
</p>
|
||||
<el-form-item label="授权方式">
|
||||
<el-switch v-model="wildcardPages" active-text="该域全部页面(含未来新增)" inactive-text="按页面精确勾选" inline-prompt style="--el-switch-on-color: var(--mg-primary, #6366f1)" />
|
||||
<el-switch
|
||||
v-model="wildcardPages"
|
||||
:disabled="!canGrantWildcard"
|
||||
active-text="该域全部可授页面(含未来新增)"
|
||||
inactive-text="按页面精确勾选"
|
||||
inline-prompt
|
||||
style="--el-switch-on-color: var(--mg-primary, #6366f1)"
|
||||
/>
|
||||
</el-form-item>
|
||||
<p v-if="!canGrantWildcard" class="page-acl-hint">你当前管理端权限未覆盖该域全部页面,不能开「全部」通配。</p>
|
||||
<div v-if="!wildcardPages" class="page-picker">
|
||||
<div v-for="g in groupedPages" :key="g.group" class="page-group">
|
||||
<div class="page-group-head">
|
||||
@@ -158,18 +171,25 @@
|
||||
</span>
|
||||
</div>
|
||||
<el-checkbox-group v-model="selectedPages" class="page-checks">
|
||||
<el-checkbox v-for="p in g.pages" :key="p.key" :value="p.key" :label="p.key">{{ p.label }}</el-checkbox>
|
||||
<el-checkbox
|
||||
v-for="p in g.pages"
|
||||
:key="p.key"
|
||||
:value="p.key"
|
||||
:label="p.key"
|
||||
:disabled="!isGrantable(p.key) && !selectedPages.includes(p.key)"
|
||||
>{{ p.label }}<span v-if="!isGrantable(p.key)" class="page-stale">(你已无权新授,可取消)</span></el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</div>
|
||||
<el-empty v-if="!groupedPages.length" description="该域暂无可分配页面" :image-size="60" />
|
||||
<el-empty v-if="!groupedPages.length" description="当前无可分配页面(请先给自己开通对应管理端页面)" :image-size="60" />
|
||||
</div>
|
||||
<div v-else class="wildcard-hint">已授予「{{ scopeLabel(roleForm.scope) }}」下的全部页面(包括未来新增的页面)。</div>
|
||||
<div v-else class="wildcard-hint">已授予「{{ scopeLabel(roleForm.scope) }}」下你可授的全部页面(包括未来新增且仍在你权限内的页面)。</div>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="roleDialog = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="submitRole">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -199,7 +219,7 @@ function roleName(id: string): string {
|
||||
return roles.value.find((r) => r.id === id)?.name ?? id
|
||||
}
|
||||
function scopeLabel(scope: string): string {
|
||||
return scope === '*' ? '通用' : scope === 'Platform' ? '管理端' : scope === 'RCSMonitor' ? '运营端' : scope
|
||||
return scope === '*' ? '通用' : scope === 'Platform' ? '管理权限' : scope === 'RCSMonitor' ? '运营权限' : scope
|
||||
}
|
||||
function scopeTag(scope: string): 'success' | 'warning' | 'danger' | 'info' {
|
||||
return scope === '*' ? 'danger' : scope === 'Platform' ? 'warning' : 'success'
|
||||
@@ -312,8 +332,37 @@ const wildcardPages = ref(false)
|
||||
const preservedOps = ref<string[]>([])
|
||||
const preservedWidgets = ref<WidgetGrant[]>([])
|
||||
|
||||
const availablePages = computed<PageDef[]>(() =>
|
||||
(catalog.value?.pages ?? []).filter((p) => roleForm.scope === '*' || p.scope === roleForm.scope))
|
||||
const availablePages = computed<PageDef[]>(() => {
|
||||
const all = (catalog.value?.pages ?? []).filter(
|
||||
(p) => roleForm.scope === '*' || p.scope === roleForm.scope
|
||||
)
|
||||
const grantable = grantableSet.value
|
||||
// 仍展示已勾但已不可新授的页,便于取消;其余只显示可授页
|
||||
return all.filter((p) => grantable.has(p.key) || selectedPages.value.includes(p.key))
|
||||
})
|
||||
|
||||
const grantableSet = computed(() => {
|
||||
const list = catalog.value?.grantablePages?.[roleForm.scope]
|
||||
if (list?.length) return new Set(list)
|
||||
// 旧后端无 grantablePages 时退回该域全部页
|
||||
return new Set(
|
||||
(catalog.value?.pages ?? [])
|
||||
.filter((p) => roleForm.scope === '*' || p.scope === roleForm.scope)
|
||||
.map((p) => p.key)
|
||||
)
|
||||
})
|
||||
|
||||
function isGrantable(key: string) {
|
||||
return grantableSet.value.has(key)
|
||||
}
|
||||
|
||||
const canGrantWildcard = computed(() => {
|
||||
const scopePages = (catalog.value?.pages ?? [])
|
||||
.filter((p) => roleForm.scope === '*' || p.scope === roleForm.scope)
|
||||
.map((p) => p.key)
|
||||
if (!scopePages.length) return false
|
||||
return scopePages.every((k) => grantableSet.value.has(k))
|
||||
})
|
||||
|
||||
const groupedPages = computed(() => {
|
||||
const map = new Map<string, PageDef[]>()
|
||||
@@ -334,32 +383,47 @@ function openCreateRole() {
|
||||
}
|
||||
function openEditRole(row: RbacRole) {
|
||||
Object.assign(roleForm, { id: row.id, name: row.name, description: row.description, scope: row.scope })
|
||||
wildcardPages.value = row.pages.includes('*')
|
||||
wildcardPages.value = row.pages.includes('*') && canGrantWildcardForScope(row.scope)
|
||||
selectedPages.value = wildcardPages.value ? [] : [...row.pages]
|
||||
preservedOps.value = [...row.ops]
|
||||
preservedWidgets.value = row.widgetGrants.map((g) => ({ ...g }))
|
||||
roleDialog.value = true
|
||||
}
|
||||
function canGrantWildcardForScope(scope: RbacScope) {
|
||||
const grantable = new Set(catalog.value?.grantablePages?.[scope] ?? [])
|
||||
if (!grantable.size) return true
|
||||
const scopePages = (catalog.value?.pages ?? [])
|
||||
.filter((p) => scope === '*' || p.scope === scope)
|
||||
.map((p) => p.key)
|
||||
return scopePages.every((k) => grantable.has(k))
|
||||
}
|
||||
function onScopeChange() {
|
||||
// 切换归属域后,剔除不属于新域的已选页面
|
||||
const valid = new Set(availablePages.value.map((p) => p.key))
|
||||
selectedPages.value = selectedPages.value.filter((k) => valid.has(k))
|
||||
if (wildcardPages.value && !canGrantWildcard.value) wildcardPages.value = false
|
||||
}
|
||||
function selectGroup(pages: PageDef[], on: boolean) {
|
||||
const keys = pages.map((p) => p.key)
|
||||
if (on) selectedPages.value = [...new Set([...selectedPages.value, ...keys])]
|
||||
const keys = pages.map((p) => p.key).filter((k) => isGrantable(k) || selectedPages.value.includes(k))
|
||||
if (on) selectedPages.value = [...new Set([...selectedPages.value, ...keys.filter(isGrantable)])]
|
||||
else selectedPages.value = selectedPages.value.filter((k) => !keys.includes(k))
|
||||
}
|
||||
async function submitRole() {
|
||||
if (!roleForm.name.trim()) { ElMessage.warning('请填写角色名称'); return }
|
||||
if (wildcardPages.value && !canGrantWildcard.value) {
|
||||
ElMessage.warning('当前权限不足以授予该域全部页面')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
const pages = wildcardPages.value
|
||||
? ['*']
|
||||
: selectedPages.value.filter((k) => isGrantable(k))
|
||||
await saveRole({
|
||||
id: roleForm.id ?? undefined,
|
||||
name: roleForm.name,
|
||||
description: roleForm.description,
|
||||
scope: roleForm.scope,
|
||||
pages: wildcardPages.value ? ['*'] : selectedPages.value,
|
||||
pages,
|
||||
ops: preservedOps.value,
|
||||
widgetGrants: preservedWidgets.value
|
||||
})
|
||||
@@ -393,6 +457,12 @@ onMounted(reloadAll)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.rbac-page {
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
}
|
||||
.rbac-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
||||
.rbac-title-wrap { display: flex; flex-direction: column; gap: 4px; }
|
||||
.rbac-title { font-weight: 600; font-size: 16px; color: var(--mg-text-light); }
|
||||
@@ -407,4 +477,6 @@ onMounted(reloadAll)
|
||||
.page-group-title { font-weight: 600; font-size: 13px; color: var(--mg-accent, #c4b5fd); }
|
||||
.page-checks { display: flex; flex-wrap: wrap; gap: 4px 18px; }
|
||||
.wildcard-hint { font-size: 13px; color: var(--mg-status-warning, #f59e0b); padding: 4px 0 8px; }
|
||||
.page-acl-hint { margin: -4px 0 12px 92px; font-size: 12px; color: var(--mg-text-muted); line-height: 1.45; }
|
||||
.page-stale { margin-left: 4px; font-size: 11px; color: var(--mg-status-warning, #f59e0b); }
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
description="电梯/卷帘门/安全门/充电桩/AP/交换机/摄像头/读码器/PLC(DeviceManagementConfig)"
|
||||
:defaults="DEFAULT_DEVICE">
|
||||
<template #default="{ payload }">
|
||||
<el-tabs model-value="drivers">
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane name="drivers" label="驱动绑定">
|
||||
<el-table :data="payload.drivers" size="small" border>
|
||||
<el-table-column label="ID" prop="id" width="120" />
|
||||
@@ -40,6 +40,20 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ConfigPageBase from '@/components/ConfigPageBase.vue'
|
||||
import { DEFAULT_DEVICE } from '@/mock/data/configs'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const activeTab = computed({
|
||||
get() {
|
||||
const t = String(route.query.tab ?? 'devices')
|
||||
return t === 'policy' || t === 'drivers' || t === 'devices' ? t : 'devices'
|
||||
},
|
||||
set(v: string) {
|
||||
void router.replace({ query: { ...route.query, tab: v } })
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,15 +1,9 @@
|
||||
<template>
|
||||
<div class="config-hub-page">
|
||||
<el-tabs v-model="activeTab" class="hub-tabs">
|
||||
<el-tab-pane name="playback" label="调度回放">
|
||||
<div class="config-pane"><PlaybackView /></div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="ops" label="运营维护" lazy>
|
||||
<el-tab-pane name="ops" label="运营维护">
|
||||
<div class="config-pane"><OpsConfigView /></div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="logs" label="日志管理" lazy>
|
||||
<div class="config-pane"><LogManagementView /></div>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane name="map-monitor" label="地图监控配置" lazy>
|
||||
<div class="config-pane"><MapMonitorConfigView /></div>
|
||||
</el-tab-pane>
|
||||
@@ -20,25 +14,40 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import PlaybackView from '@/views/admin/PlaybackView.vue'
|
||||
import OpsConfigView from '@/views/admin/config/OpsConfigView.vue'
|
||||
import LogManagementView from '@/views/admin/LogManagementView.vue'
|
||||
import MapMonitorConfigView from '@/views/admin/config/MapMonitorConfigView.vue'
|
||||
|
||||
// 聚合页:运维与回放(调度回放从顶级菜单移入此处)。Tab 与 URL ?tab= 同步。
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const TAB_NAMES = ['playback', 'ops', 'logs', 'map-monitor'] as const
|
||||
const TAB_NAMES = ['ops', 'map-monitor'] as const
|
||||
type TabName = (typeof TAB_NAMES)[number]
|
||||
|
||||
function relocatedPath(): string | null {
|
||||
const q = route.query.tab
|
||||
if (q === 'playback') return '/admin/playback'
|
||||
if (q === 'logs') return '/admin/logs'
|
||||
return null
|
||||
}
|
||||
|
||||
function readTab(): TabName {
|
||||
const q = route.query.tab
|
||||
return typeof q === 'string' && (TAB_NAMES as readonly string[]).includes(q) ? (q as TabName) : 'playback'
|
||||
return typeof q === 'string' && (TAB_NAMES as readonly string[]).includes(q) ? (q as TabName) : 'ops'
|
||||
}
|
||||
|
||||
const activeTab = ref<TabName>(readTab())
|
||||
|
||||
const relocated = relocatedPath()
|
||||
if (relocated) router.replace(relocated)
|
||||
|
||||
watch(activeTab, (t) => {
|
||||
if (route.query.tab !== t) router.replace({ query: { ...route.query, tab: t } })
|
||||
})
|
||||
watch(() => route.query.tab, () => {
|
||||
const nextPath = relocatedPath()
|
||||
if (nextPath) {
|
||||
router.replace(nextPath)
|
||||
return
|
||||
}
|
||||
const next = readTab()
|
||||
if (next !== activeTab.value) activeTab.value = next
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<div style="display: flex; align-items: center; gap: 8px">
|
||||
<span>运营备注(monitor.note.write · 写 platform.db.Annotations)</span>
|
||||
<span>运维备注(monitor.note.write)</span>
|
||||
<el-tag size="small" type="success" v-if="auth.hasOp('monitor.note.write')">可写</el-tag>
|
||||
<el-tag size="small" type="danger" v-else>只读</el-tag>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user