diff --git a/MiGu.Server/Auth/RbacStore.cs b/MiGu.Server/Auth/RbacStore.cs index 4cbc269..32a5cdd 100644 --- a/MiGu.Server/Auth/RbacStore.cs +++ b/MiGu.Server/Auth/RbacStore.cs @@ -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); + } + } + /// 当前用户可登录的 scope 集合(其角色覆盖的 scope,* 角色覆盖全部)。 public List UsableScopes(RbacUser user) { diff --git a/MiGu.Server/Controllers/DashboardController.cs b/MiGu.Server/Controllers/DashboardController.cs new file mode 100644 index 0000000..0b6d72f --- /dev/null +++ b/MiGu.Server/Controllers/DashboardController.cs @@ -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? Keys); + + [HttpGet("quick-entries")] + public async Task 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 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); + } +} diff --git a/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs new file mode 100644 index 0000000..fe38d76 --- /dev/null +++ b/MiGu.Server/Dashboard/DashboardShortcutCatalog.cs @@ -0,0 +1,99 @@ +using MiGu.Server.Auth; + +namespace MiGu.Server.Dashboard; + +/// +/// Dashboard 快捷入口 key 白名单。key 与前端 quickEntries.ts 对齐; +/// 用于 RBAC 校验(用户须有权访问对应页面)。 +/// +public sealed record ShortcutDef(string Key, string PageKey, string Scope); + +public static class DashboardShortcutCatalog +{ + /// 旧版快捷 key(别名)→ 菜单 key。保存时归一化,避免与菜单项重复。 + private static readonly Dictionary 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 ByKey = + PlatformShortcuts.Concat(MonitorShortcuts) + .ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase); + + public static readonly int MaxKeysPerUser = 16; + + public static readonly IReadOnlyList DefaultPlatformKeys = + [ + "admin-map-editor", + "admin-task-templates", + "admin-cars", + "admin-config-system-center", + "admin-config-ops-center", + "admin-config-strategy" + ]; + + public static readonly IReadOnlyList DefaultMonitorKeys = + ["monitor-vehicle-hub", "monitor-map", "monitor-ops"]; + + private static readonly HashSet 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 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(); +} diff --git a/MiGu.Server/Dashboard/DashboardShortcutService.cs b/MiGu.Server/Dashboard/DashboardShortcutService.cs new file mode 100644 index 0000000..583cdb2 --- /dev/null +++ b/MiGu.Server/Dashboard/DashboardShortcutService.cs @@ -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 Keys, bool UsingDefaults); + + public async Task 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 SaveAsync( + string userId, string scope, IReadOnlyList? 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 AllowedPages(string userId, string scope) + { + var user = _rbac.FindUserById(userId); + if (user == null || !user.Enabled) + return new HashSet(StringComparer.OrdinalIgnoreCase); + + var eff = _rbac.ComputeEffective(user, scope); + return eff.Pages.ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static List FilterKeys( + IEnumerable keys, string scope, HashSet allowedPages) + { + var outKeys = new List(); + var seen = new HashSet(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 Deduplicate(IReadOnlyList keys) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + var list = new List(); + 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 ParseKeys(string json) + { + try + { + return JsonSerializer.Deserialize>(json, JsonOpts) ?? []; + } + catch + { + return []; + } + } + + private static string NormalizeScope(string scope) => + string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase) + ? PageCatalog.ScopeMonitor + : PageCatalog.ScopePlatform; +} diff --git a/MiGu.Server/Dashboard/UserDashboardShortcut.cs b/MiGu.Server/Dashboard/UserDashboardShortcut.cs new file mode 100644 index 0000000..7d39f3a --- /dev/null +++ b/MiGu.Server/Dashboard/UserDashboardShortcut.cs @@ -0,0 +1,10 @@ +namespace MiGu.Server.Dashboard; + +/// 用户 Dashboard 快捷入口配置(按 user + scope 一行)。 +public sealed class UserDashboardShortcut +{ + public string UserId { get; set; } = ""; + public string Scope { get; set; } = ""; + public string KeysJson { get; set; } = "[]"; + public DateTimeOffset UpdatedAt { get; set; } +} diff --git a/MiGu.Server/Persistence/PlatformDbContext.cs b/MiGu.Server/Persistence/PlatformDbContext.cs index 96ccc08..361f332 100644 --- a/MiGu.Server/Persistence/PlatformDbContext.cs +++ b/MiGu.Server/Persistence/PlatformDbContext.cs @@ -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 ContainerLocationHistories => Set(); public DbSet ContainerMaterialHistories => Set(); public DbSet SimpleFields => Set(); + public DbSet UserDashboardShortcuts => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -59,6 +61,21 @@ public sealed class PlatformDbContext : DbContext modelBuilder.Entity().Property(x => x.QuantityDelta).HasPrecision(18, 4); ConfigureSimpleField(modelBuilder); + ConfigureUserDashboardShortcut(modelBuilder); + } + + private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder) + { + var e = modelBuilder.Entity(); + 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( + 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) diff --git a/MiGu.Server/Persistence/PlatformPersistence.cs b/MiGu.Server/Persistence/PlatformPersistence.cs index 9b6cab8..1fe3a42 100644 --- a/MiGu.Server/Persistence/PlatformPersistence.cs +++ b/MiGu.Server/Persistence/PlatformPersistence.cs @@ -41,6 +41,7 @@ public static class PlatformPersistence services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); return services; } @@ -51,6 +52,7 @@ public static class PlatformPersistence await db.Database.EnsureCreatedAsync(); // EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。 await EnsureSimpleFieldsTableAsync(db); + await EnsureUserDashboardShortcutsTableAsync(db); } /// 为已存在的数据库补建 simple_fields 表(幂等)。 @@ -99,6 +101,30 @@ public static class PlatformPersistence } } + /// 为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。 + 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(); + await creator.CreateTablesAsync(); + } + } + /// /// 检查表是否存在 /// diff --git a/MiGu.Server/data/platform.db b/MiGu.Server/data/platform.db index 3eff1fb..1d1e6fe 100644 Binary files a/MiGu.Server/data/platform.db and b/MiGu.Server/data/platform.db differ diff --git a/frontends/apps/simple-platform-vue/src/api/dashboardQuickEntries.ts b/frontends/apps/simple-platform-vue/src/api/dashboardQuickEntries.ts new file mode 100644 index 0000000..8cec182 --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/api/dashboardQuickEntries.ts @@ -0,0 +1,51 @@ +import http from '@/api/http' + +export interface QuickEntriesDto { + keys: string[] + usingDefaults: boolean +} + +const MOCK_STORAGE_KEY = 'simple.mock.dashboard.quickEntries' + +function mockStorageKey(scope: string, userId: string) { + return `${MOCK_STORAGE_KEY}.${scope}.${userId}` +} + +function isMock() { + return import.meta.env.VITE_USE_MOCK === 'true' +} + +function mockLoad(scope: string, userId: string): QuickEntriesDto | null { + try { + const raw = localStorage.getItem(mockStorageKey(scope, userId)) + return raw ? (JSON.parse(raw) as QuickEntriesDto) : null + } catch { + return null + } +} + +function mockSave(scope: string, userId: string, dto: QuickEntriesDto) { + try { + localStorage.setItem(mockStorageKey(scope, userId), JSON.stringify(dto)) + } catch { /* ignore */ } +} + +export async function fetchQuickEntryKeys(userId: string, scope: string): Promise { + if (isMock()) { + return mockLoad(scope, userId) ?? { keys: [], usingDefaults: true } + } + const { data } = await http.get('/dashboard/quick-entries') + return data +} + +export async function saveQuickEntryKeys( + userId: string, scope: string, keys: string[] +): Promise { + if (isMock()) { + const dto: QuickEntriesDto = { keys, usingDefaults: false } + mockSave(scope, userId, dto) + return dto + } + const { data } = await http.put('/dashboard/quick-entries', { keys }) + return data +} diff --git a/frontends/apps/simple-platform-vue/src/components/dashboard/QuickEntryPickerDialog.vue b/frontends/apps/simple-platform-vue/src/components/dashboard/QuickEntryPickerDialog.vue new file mode 100644 index 0000000..d0576bd --- /dev/null +++ b/frontends/apps/simple-platform-vue/src/components/dashboard/QuickEntryPickerDialog.vue @@ -0,0 +1,211 @@ + + + + + + + diff --git a/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue b/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue index 798cdf7..3b04b29 100644 --- a/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue +++ b/frontends/apps/simple-platform-vue/src/components/fleet/FleetAllocationPanel.vue @@ -1,18 +1,25 @@