优化小车卡片界面和车队编队的功能,优化首页菜单的快捷入口功能
This commit is contained in:
@@ -212,6 +212,15 @@ public sealed class RbacStore
|
||||
lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); }
|
||||
}
|
||||
|
||||
public RbacUser? FindUserById(string id)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id);
|
||||
return u is null ? null : Clone(u);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前用户可登录的 scope 集合(其角色覆盖的 scope,<c>*</c> 角色覆盖全部)。</summary>
|
||||
public List<string> UsableScopes(RbacUser user)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MiGu.Server.Dashboard;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/dashboard")]
|
||||
public class DashboardController : ControllerBase
|
||||
{
|
||||
private readonly DashboardShortcutService _shortcuts;
|
||||
|
||||
public DashboardController(DashboardShortcutService shortcuts) => _shortcuts = shortcuts;
|
||||
|
||||
public sealed record SaveQuickEntriesRequest(List<string>? Keys);
|
||||
|
||||
[HttpGet("quick-entries")]
|
||||
public async Task<IActionResult> GetQuickEntries(CancellationToken ct)
|
||||
{
|
||||
var (userId, scope, err) = ResolveSession();
|
||||
if (err != null) return err;
|
||||
|
||||
var result = await _shortcuts.GetAsync(userId!, scope!, ct);
|
||||
return Ok(new { keys = result.Keys, usingDefaults = result.UsingDefaults });
|
||||
}
|
||||
|
||||
[HttpPut("quick-entries")]
|
||||
public async Task<IActionResult> SaveQuickEntries(
|
||||
[FromBody] SaveQuickEntriesRequest req, CancellationToken ct)
|
||||
{
|
||||
var (userId, scope, err) = ResolveSession();
|
||||
if (err != null) return err;
|
||||
|
||||
var result = await _shortcuts.SaveAsync(userId!, scope!, req.Keys, ct);
|
||||
return Ok(new { keys = result.Keys, usingDefaults = result.UsingDefaults });
|
||||
}
|
||||
|
||||
private (string? UserId, string? Scope, IActionResult? Error) ResolveSession()
|
||||
{
|
||||
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
return (null, null, Unauthorized(new { message = "未识别用户" }));
|
||||
|
||||
var scope = User.FindFirstValue("scope");
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
return (null, null, BadRequest(new { message = "会话缺少 scope" }));
|
||||
|
||||
return (userId, scope, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using MiGu.Server.Auth;
|
||||
|
||||
namespace MiGu.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Dashboard 快捷入口 key 白名单。key 与前端 <c>quickEntries.ts</c> 对齐;
|
||||
/// <see cref="PageKey"/> 用于 RBAC 校验(用户须有权访问对应页面)。
|
||||
/// </summary>
|
||||
public sealed record ShortcutDef(string Key, string PageKey, string Scope);
|
||||
|
||||
public static class DashboardShortcutCatalog
|
||||
{
|
||||
/// <summary>旧版快捷 key(别名)→ 菜单 key。保存时归一化,避免与菜单项重复。</summary>
|
||||
private static readonly Dictionary<string, string> LegacyKeyAliases =
|
||||
new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["platform-config"] = "admin-map-editor",
|
||||
["mission"] = "admin-task-templates",
|
||||
["cars"] = "admin-cars",
|
||||
["auth"] = "admin-config-system-center",
|
||||
["system"] = "admin-config-system-center",
|
||||
["ops"] = "admin-config-ops-center",
|
||||
["tasks"] = "admin-config-strategy",
|
||||
};
|
||||
|
||||
private static readonly ShortcutDef[] PlatformShortcuts =
|
||||
[
|
||||
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
|
||||
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
|
||||
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
|
||||
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
|
||||
new("admin-project-properties", "admin-project-properties", PageCatalog.ScopePlatform),
|
||||
new("admin-tracks", "admin-tracks", PageCatalog.ScopePlatform),
|
||||
new("admin-cars", "admin-cars", PageCatalog.ScopePlatform),
|
||||
new("admin-processes", "admin-processes", PageCatalog.ScopePlatform),
|
||||
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
|
||||
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
|
||||
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
|
||||
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
|
||||
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
|
||||
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
|
||||
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),
|
||||
];
|
||||
|
||||
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),
|
||||
new("monitor-notes", "monitor-notes", PageCatalog.ScopeMonitor),
|
||||
];
|
||||
|
||||
private static readonly Dictionary<string, ShortcutDef> ByKey =
|
||||
PlatformShortcuts.Concat(MonitorShortcuts)
|
||||
.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static readonly int MaxKeysPerUser = 16;
|
||||
|
||||
public static readonly IReadOnlyList<string> DefaultPlatformKeys =
|
||||
[
|
||||
"admin-map-editor",
|
||||
"admin-task-templates",
|
||||
"admin-cars",
|
||||
"admin-config-system-center",
|
||||
"admin-config-ops-center",
|
||||
"admin-config-strategy"
|
||||
];
|
||||
|
||||
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" };
|
||||
|
||||
public static bool IsValidKey(string key) =>
|
||||
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
|
||||
|
||||
public static ShortcutDef? TryGet(string key) =>
|
||||
ByKey.TryGetValue(key, out var def) ? def : null;
|
||||
|
||||
public static IReadOnlyList<string> DefaultKeysForScope(string scope) =>
|
||||
string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase)
|
||||
? DefaultMonitorKeys
|
||||
: DefaultPlatformKeys;
|
||||
|
||||
public static bool KeyMatchesScope(string key, string scope)
|
||||
{
|
||||
var def = TryGet(key);
|
||||
return def != null && string.Equals(def.Scope, scope, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static string PageKeyFor(string key) => TryGet(NormalizeKey(key))?.PageKey ?? NormalizeKey(key);
|
||||
|
||||
public static string NormalizeKey(string key) =>
|
||||
LegacyKeyAliases.TryGetValue(key.Trim(), out var canon) ? canon : key.Trim();
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MiGu.Server.Auth;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardShortcutService
|
||||
{
|
||||
private readonly PlatformDbContext _db;
|
||||
private readonly RbacStore _rbac;
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
};
|
||||
|
||||
public DashboardShortcutService(PlatformDbContext db, RbacStore rbac)
|
||||
{
|
||||
_db = db;
|
||||
_rbac = rbac;
|
||||
}
|
||||
|
||||
public sealed record QuickEntriesResult(IReadOnlyList<string> Keys, bool UsingDefaults);
|
||||
|
||||
public async Task<QuickEntriesResult> GetAsync(string userId, string scope, CancellationToken ct = default)
|
||||
{
|
||||
scope = NormalizeScope(scope);
|
||||
var allowed = AllowedPages(userId, scope);
|
||||
|
||||
var row = await _db.UserDashboardShortcuts
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId && x.Scope == scope, ct);
|
||||
|
||||
if (row == null)
|
||||
{
|
||||
var defaults = FilterKeys(DashboardShortcutCatalog.DefaultKeysForScope(scope), scope, allowed);
|
||||
return new QuickEntriesResult(
|
||||
defaults.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList(),
|
||||
UsingDefaults: true);
|
||||
}
|
||||
|
||||
var keys = ParseKeys(row.KeysJson);
|
||||
var filtered = FilterKeys(keys, scope, allowed)
|
||||
.Take(DashboardShortcutCatalog.MaxKeysPerUser)
|
||||
.ToList();
|
||||
return new QuickEntriesResult(filtered, UsingDefaults: false);
|
||||
}
|
||||
|
||||
public async Task<QuickEntriesResult> SaveAsync(
|
||||
string userId, string scope, IReadOnlyList<string>? keys, CancellationToken ct = default)
|
||||
{
|
||||
scope = NormalizeScope(scope);
|
||||
var allowed = AllowedPages(userId, scope);
|
||||
var sanitized = FilterKeys(Deduplicate(keys ?? []), scope, allowed);
|
||||
if (sanitized.Count > DashboardShortcutCatalog.MaxKeysPerUser)
|
||||
sanitized = sanitized.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList();
|
||||
|
||||
var row = await _db.UserDashboardShortcuts
|
||||
.FirstOrDefaultAsync(x => x.UserId == userId && x.Scope == scope, ct);
|
||||
|
||||
var json = JsonSerializer.Serialize(sanitized, JsonOpts);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
if (row == null)
|
||||
{
|
||||
_db.UserDashboardShortcuts.Add(new UserDashboardShortcut
|
||||
{
|
||||
UserId = userId,
|
||||
Scope = scope,
|
||||
KeysJson = json,
|
||||
UpdatedAt = now
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
row.KeysJson = json;
|
||||
row.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new QuickEntriesResult(sanitized, UsingDefaults: false);
|
||||
}
|
||||
|
||||
private HashSet<string> AllowedPages(string userId, string scope)
|
||||
{
|
||||
var user = _rbac.FindUserById(userId);
|
||||
if (user == null || !user.Enabled)
|
||||
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var eff = _rbac.ComputeEffective(user, scope);
|
||||
return eff.Pages.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static List<string> FilterKeys(
|
||||
IEnumerable<string> keys, string scope, HashSet<string> allowedPages)
|
||||
{
|
||||
var outKeys = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var raw in keys)
|
||||
{
|
||||
var key = DashboardShortcutCatalog.NormalizeKey(raw ?? "");
|
||||
if (string.IsNullOrEmpty(key)) continue;
|
||||
if (!DashboardShortcutCatalog.IsValidKey(key)) continue;
|
||||
if (!DashboardShortcutCatalog.KeyMatchesScope(key, scope)) continue;
|
||||
if (seen.Contains(key)) continue;
|
||||
|
||||
var pageKey = DashboardShortcutCatalog.PageKeyFor(key);
|
||||
if (!allowedPages.Contains(pageKey)) continue;
|
||||
|
||||
seen.Add(key);
|
||||
outKeys.Add(key);
|
||||
}
|
||||
return outKeys;
|
||||
}
|
||||
|
||||
private static List<string> Deduplicate(IReadOnlyList<string> keys)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var list = new List<string>();
|
||||
foreach (var k in keys)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(k)) continue;
|
||||
var t = k.Trim();
|
||||
if (seen.Add(t)) list.Add(t);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<string> ParseKeys(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
|
||||
}
|
||||
catch
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeScope(string scope) =>
|
||||
string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase)
|
||||
? PageCatalog.ScopeMonitor
|
||||
: PageCatalog.ScopePlatform;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace MiGu.Server.Dashboard;
|
||||
|
||||
/// <summary>用户 Dashboard 快捷入口配置(按 user + scope 一行)。</summary>
|
||||
public sealed class UserDashboardShortcut
|
||||
{
|
||||
public string UserId { get; set; } = "";
|
||||
public string Scope { get; set; } = "";
|
||||
public string KeysJson { get; set; } = "[]";
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using MiGu.Server.Dashboard;
|
||||
using MiGu.Server.Wms;
|
||||
using MiGu.Server.SimpleFields;
|
||||
|
||||
@@ -18,6 +19,7 @@ public sealed class PlatformDbContext : DbContext
|
||||
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
|
||||
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
|
||||
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
|
||||
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -59,6 +61,21 @@ public sealed class PlatformDbContext : DbContext
|
||||
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
|
||||
|
||||
ConfigureSimpleField(modelBuilder);
|
||||
ConfigureUserDashboardShortcut(modelBuilder);
|
||||
}
|
||||
|
||||
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
|
||||
{
|
||||
var e = modelBuilder.Entity<UserDashboardShortcut>();
|
||||
e.ToTable("user_dashboard_shortcuts");
|
||||
e.HasKey(x => new { x.UserId, x.Scope });
|
||||
e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
|
||||
e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
|
||||
e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
|
||||
var dateTime = new ValueConverter<DateTimeOffset, string>(
|
||||
v => v.UtcDateTime.ToString("O"),
|
||||
v => DateTimeOffset.Parse(v));
|
||||
e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
|
||||
}
|
||||
|
||||
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
|
||||
|
||||
@@ -41,6 +41,7 @@ public static class PlatformPersistence
|
||||
services.AddScoped<WmsReferenceValidator>();
|
||||
services.AddScoped<WmsService>();
|
||||
services.AddScoped<SimpleFieldService>();
|
||||
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ public static class PlatformPersistence
|
||||
await db.Database.EnsureCreatedAsync();
|
||||
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
|
||||
await EnsureSimpleFieldsTableAsync(db);
|
||||
await EnsureUserDashboardShortcutsTableAsync(db);
|
||||
}
|
||||
|
||||
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
|
||||
@@ -99,6 +101,30 @@ public static class PlatformPersistence
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
|
||||
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
|
||||
{
|
||||
if (db.Database.IsSqlite())
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts (
|
||||
user_id TEXT NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
keys_json TEXT NOT NULL DEFAULT '[]',
|
||||
updated_at TEXT NOT NULL,
|
||||
CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope)
|
||||
);
|
||||
""");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await TableExistsAsync(db, "user_dashboard_shortcuts"))
|
||||
{
|
||||
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
|
||||
await creator.CreateTablesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 检查表是否存在
|
||||
/// </summary>
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user