merge
This commit is contained in:
@@ -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,85 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using MiGu.Server.SimpleFields;
|
||||
|
||||
namespace MiGu.Server.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Simple 字段管理 API:按车型维护 site / track / plan / car 四类反射字段的默认值与多语言名称。
|
||||
/// 数据持久化于 <c>platform.db</c> 的 <c>simple_fields</c> 表。
|
||||
///
|
||||
/// 对应前端「字段管理」页(<c>/admin/simple-fields</c>,页面 key <c>admin-simple-fields</c>)。
|
||||
/// 前端主流程为「刷新」读库、「默认」从 SimpleLite 拉取模板、「保存」走 <see cref="SaveBatch"/> 全量替换。
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[TypeFilter(typeof(SimpleFieldExceptionFilter))]
|
||||
[Route("api/simple-fields")]
|
||||
public sealed class SimpleFieldController : ControllerBase
|
||||
{
|
||||
private readonly SimpleFieldService _service;
|
||||
|
||||
public SimpleFieldController(SimpleFieldService service) => _service = service;
|
||||
|
||||
/// <summary>
|
||||
/// 查询字段列表,支持按字段类型、车型与关键字过滤。
|
||||
/// 结果按 car_type → field_type → key 排序。
|
||||
/// </summary>
|
||||
/// <param name="fieldType">字段类型,如 <c>siteFields</c>、<c>carFields</c>。</param>
|
||||
/// <param name="carType">车型唯一标识:<c>assemblyName.shortName</c>。</param>
|
||||
/// <param name="q">关键字,匹配 key / car_type / 中英文名 / 其他语言 / 默认值。</param>
|
||||
[HttpGet]
|
||||
public Task<List<SimpleField>> List([FromQuery] string? fieldType, [FromQuery] string? carType, [FromQuery] string? q) => _service.ListAsync(fieldType, carType, q);
|
||||
|
||||
/// <summary>
|
||||
/// 新增单条字段;同车型 + 字段类型下 key 不可重复
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public Task<SimpleField> Create([FromBody] SimpleFieldRequest req) => _service.SaveAsync(req);
|
||||
|
||||
/// <summary>
|
||||
/// 按 id 更新单条字段
|
||||
/// </summary>
|
||||
[HttpPut("{id:guid}")]
|
||||
public Task<SimpleField> Update(Guid id, [FromBody] SimpleFieldRequest req) => _service.SaveAsync(req with { Id = id });
|
||||
|
||||
/// <summary>
|
||||
/// 按 id 删除单条字段
|
||||
/// </summary>
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
await _service.DeleteAsync(id);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量保存字段
|
||||
/// <paramref name="req"/>.<see cref="SimpleFieldBatchRequest.ReplaceAll"/> 为 <c>true</c> 时先清空表再写入(前端「保存」使用此模式)。
|
||||
/// 返回实际写入条数 <c>{ count }</c>。
|
||||
/// </summary>
|
||||
[HttpPost("batch")]
|
||||
public async Task<IActionResult> SaveBatch([FromBody] SimpleFieldBatchRequest req)
|
||||
{
|
||||
var count = await _service.SaveBatchAsync(req);
|
||||
|
||||
return Ok(new { count });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 <see cref="SimpleFieldException"/> 转为 HTTP 400,响应体 <c>{ message }</c>
|
||||
/// </summary>
|
||||
public sealed class SimpleFieldExceptionFilter : IExceptionFilter
|
||||
{
|
||||
public void OnException(ExceptionContext context)
|
||||
{
|
||||
if (context.Exception is not SimpleFieldException ex) { return; }
|
||||
|
||||
context.Result = new BadRequestObjectResult(new { message = ex.Message });
|
||||
|
||||
context.ExceptionHandled = true;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.OpenApi.Models;
|
||||
using Swashbuckle.AspNetCore.SwaggerGen;
|
||||
|
||||
namespace MiGu.Server.OpenApi;
|
||||
|
||||
/// <summary>
|
||||
/// 将 SimpleLite EmbedIO WebApi 的 OpenAPI 描述合并进 MiGu.Server Swagger 文档。
|
||||
/// 源文件:Simple/SimpleLite/Docs/openapi/simplelite-projection.json
|
||||
/// </summary>
|
||||
public sealed class SimpleLiteOpenApiDocumentFilter : IDocumentFilter
|
||||
{
|
||||
private static readonly Dictionary<string, OperationType> VerbMap = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["get"] = OperationType.Get,
|
||||
["post"] = OperationType.Post,
|
||||
["put"] = OperationType.Put,
|
||||
["patch"] = OperationType.Patch,
|
||||
["delete"] = OperationType.Delete,
|
||||
["head"] = OperationType.Head,
|
||||
["options"] = OperationType.Options,
|
||||
["trace"] = OperationType.Trace
|
||||
};
|
||||
|
||||
public void Apply(OpenApiDocument swaggerDoc, DocumentFilterContext context)
|
||||
{
|
||||
var json = TryLoadJson();
|
||||
if (json == null) return;
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("tags", out var tagsEl) && tagsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
swaggerDoc.Tags ??= new List<OpenApiTag>();
|
||||
foreach (var tag in tagsEl.EnumerateArray())
|
||||
{
|
||||
if (!tag.TryGetProperty("name", out var nameEl)) continue;
|
||||
var name = nameEl.GetString();
|
||||
if (string.IsNullOrEmpty(name) || swaggerDoc.Tags.Any(t => t.Name == name)) continue;
|
||||
var desc = tag.TryGetProperty("description", out var d) ? d.GetString() : null;
|
||||
swaggerDoc.Tags.Add(new OpenApiTag { Name = name, Description = desc });
|
||||
}
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("paths", out var pathsEl)) return;
|
||||
|
||||
foreach (var pathProp in pathsEl.EnumerateObject())
|
||||
{
|
||||
var fullPath = pathProp.Name.StartsWith("/api/sl", StringComparison.Ordinal)
|
||||
? pathProp.Name
|
||||
: "/api/sl" + pathProp.Name;
|
||||
|
||||
var pathItem = new OpenApiPathItem();
|
||||
foreach (var opProp in pathProp.Value.EnumerateObject())
|
||||
{
|
||||
if (!VerbMap.TryGetValue(opProp.Name, out var verb)) continue;
|
||||
pathItem.Operations[verb] = ParseOperation(opProp.Value);
|
||||
}
|
||||
|
||||
if (pathItem.Operations.Count > 0)
|
||||
swaggerDoc.Paths[fullPath] = pathItem;
|
||||
}
|
||||
}
|
||||
|
||||
private static OpenApiOperation ParseOperation(JsonElement el)
|
||||
{
|
||||
var op = new OpenApiOperation
|
||||
{
|
||||
Summary = el.TryGetProperty("summary", out var s) ? s.GetString() : null,
|
||||
Description = el.TryGetProperty("description", out var d) ? d.GetString() : null
|
||||
};
|
||||
|
||||
if (el.TryGetProperty("tags", out var tags) && tags.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var t in tags.EnumerateArray())
|
||||
{
|
||||
var name = t.GetString();
|
||||
if (!string.IsNullOrEmpty(name))
|
||||
op.Tags.Add(new OpenApiTag { Name = name });
|
||||
}
|
||||
}
|
||||
|
||||
if (el.TryGetProperty("parameters", out var parameters) && parameters.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var p in parameters.EnumerateArray())
|
||||
{
|
||||
var param = new OpenApiParameter
|
||||
{
|
||||
Name = p.TryGetProperty("name", out var n) ? n.GetString() : null,
|
||||
In = p.TryGetProperty("in", out var loc) ? ParameterLocationFrom(loc.GetString()) : null,
|
||||
Required = p.TryGetProperty("required", out var req) && req.GetBoolean(),
|
||||
Description = p.TryGetProperty("description", out var pd) ? pd.GetString() : null
|
||||
};
|
||||
if (p.TryGetProperty("schema", out var schema))
|
||||
param.Schema = ParseSchema(schema);
|
||||
op.Parameters.Add(param);
|
||||
}
|
||||
}
|
||||
|
||||
if (el.TryGetProperty("requestBody", out var body))
|
||||
op.RequestBody = ParseRequestBody(body);
|
||||
|
||||
if (el.TryGetProperty("responses", out var responses))
|
||||
{
|
||||
foreach (var resp in responses.EnumerateObject())
|
||||
{
|
||||
op.Responses[resp.Name] = new OpenApiResponse
|
||||
{
|
||||
Description = resp.Value.TryGetProperty("description", out var rd)
|
||||
? rd.GetString() ?? ""
|
||||
: ""
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return op;
|
||||
}
|
||||
|
||||
private static OpenApiRequestBody? ParseRequestBody(JsonElement el)
|
||||
{
|
||||
if (!el.TryGetProperty("content", out var content)) return null;
|
||||
var body = new OpenApiRequestBody();
|
||||
foreach (var ct in content.EnumerateObject())
|
||||
{
|
||||
var media = new OpenApiMediaType();
|
||||
if (ct.Value.TryGetProperty("schema", out var schema))
|
||||
media.Schema = ParseSchema(schema);
|
||||
body.Content[ct.Name] = media;
|
||||
}
|
||||
return body.Content.Count > 0 ? body : null;
|
||||
}
|
||||
|
||||
private static OpenApiSchema ParseSchema(JsonElement el)
|
||||
{
|
||||
var schema = new OpenApiSchema();
|
||||
if (el.TryGetProperty("type", out var t)) schema.Type = t.GetString();
|
||||
if (el.TryGetProperty("description", out var d)) schema.Description = d.GetString();
|
||||
if (el.TryGetProperty("$ref", out var r))
|
||||
{
|
||||
var refId = r.GetString()?.TrimStart('#', '/');
|
||||
if (!string.IsNullOrEmpty(refId))
|
||||
schema.Reference = new OpenApiReference { Id = refId, Type = ReferenceType.Schema };
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
private static ParameterLocation? ParameterLocationFrom(string? loc) => loc switch
|
||||
{
|
||||
"query" => ParameterLocation.Query,
|
||||
"path" => ParameterLocation.Path,
|
||||
"header" => ParameterLocation.Header,
|
||||
"cookie" => ParameterLocation.Cookie,
|
||||
_ => null
|
||||
};
|
||||
|
||||
private static string? TryLoadJson()
|
||||
{
|
||||
foreach (var candidate in ResolveCandidatePaths())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
return File.ReadAllText(candidate);
|
||||
}
|
||||
catch { /* next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ResolveCandidatePaths()
|
||||
{
|
||||
var roots = new[] { AppContext.BaseDirectory, Directory.GetCurrentDirectory() }
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
yield return Path.GetFullPath(Path.Combine(root, "OpenApi", "simplelite-projection.json"));
|
||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
|
||||
yield return Path.GetFullPath(Path.Combine(root, "..", "..", "Simple", "SimpleLite", "Docs", "openapi", "simplelite-projection.json"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace MiGu.Server.SimpleFields;
|
||||
|
||||
public static class SimpleFieldDateTime
|
||||
{
|
||||
public const string StorageFormat = "yyyy-MM-dd HH:mm:ss";
|
||||
|
||||
public static DateTimeOffset Now => DateTimeOffset.Now;
|
||||
|
||||
public static string ToStorage(DateTimeOffset value) =>
|
||||
value.LocalDateTime.ToString(StorageFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
public static DateTimeOffset FromStorage(string value)
|
||||
{
|
||||
if (DateTime.TryParseExact(value, StorageFormat, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var local))
|
||||
return new DateTimeOffset(local);
|
||||
|
||||
return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MiGu.Server.SimpleFields;
|
||||
|
||||
public sealed class SimpleField
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>车型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。</summary>
|
||||
[MaxLength(64)]
|
||||
public string CarType { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 字段类型唯一标识:assemblyName.shortName,如 StandardScene.QrLidar.Forklift。
|
||||
/// </summary>
|
||||
[MaxLength(64)]
|
||||
public string FieldType { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 字段唯一标识:key,如 Forklift.PositionX。
|
||||
/// </summary>
|
||||
[MaxLength(128)]
|
||||
public string Key { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 字段值,如 10.0。
|
||||
/// </summary>
|
||||
public string Value { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 数据类型,如 System.String。
|
||||
/// </summary>
|
||||
[MaxLength(128)]
|
||||
public string DataType { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 中文名称,如 位置 X。
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? Chinese { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 英文名称,如 Position X。
|
||||
/// </summary>
|
||||
[MaxLength(256)]
|
||||
public string? English { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 其他语言名称,如 位置 X。
|
||||
/// </summary>
|
||||
[MaxLength(512)]
|
||||
public string Other { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// 是否内置默认字段,如 true。
|
||||
/// </summary>
|
||||
public bool IsDefault { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 创建时间,如 2021-01-01 12:00:00。
|
||||
/// </summary>
|
||||
public DateTimeOffset CreateTime { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 更新时间,如 2021-01-01 12:00:00。
|
||||
/// </summary>
|
||||
public DateTimeOffset UpdateTime { get; set; }
|
||||
}
|
||||
|
||||
public sealed record SimpleFieldRequest(
|
||||
Guid? Id,
|
||||
string CarType,
|
||||
string FieldType,
|
||||
string Key,
|
||||
string? Value,
|
||||
string? DataType,
|
||||
string? Chinese,
|
||||
string? English,
|
||||
string? Other,
|
||||
bool IsDefault);
|
||||
|
||||
public sealed record SimpleFieldBatchRequest(
|
||||
bool ReplaceAll,
|
||||
List<SimpleFieldRequest> Items);
|
||||
@@ -0,0 +1,132 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MiGu.Server.Persistence;
|
||||
|
||||
namespace MiGu.Server.SimpleFields;
|
||||
|
||||
public sealed class SimpleFieldService
|
||||
{
|
||||
private readonly PlatformDbContext _db;
|
||||
|
||||
public SimpleFieldService(PlatformDbContext db) => _db = db;
|
||||
|
||||
public async Task<List<SimpleField>> ListAsync(string? fieldType = null, string? carType = null, string? q = null)
|
||||
{
|
||||
var query = _db.SimpleFields.AsNoTracking()
|
||||
.OrderBy(x => x.CarType).ThenBy(x => x.FieldType).ThenBy(x => x.Key)
|
||||
.AsQueryable();
|
||||
if (!string.IsNullOrWhiteSpace(fieldType))
|
||||
query = query.Where(x => x.FieldType == fieldType);
|
||||
if (!string.IsNullOrWhiteSpace(carType))
|
||||
query = query.Where(x => x.CarType == carType);
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var kw = q.Trim();
|
||||
query = query.Where(x =>
|
||||
x.Key.Contains(kw) ||
|
||||
x.CarType.Contains(kw) ||
|
||||
(x.Chinese != null && x.Chinese.Contains(kw)) ||
|
||||
(x.English != null && x.English.Contains(kw)) ||
|
||||
x.Other.Contains(kw) ||
|
||||
x.Value.Contains(kw));
|
||||
}
|
||||
return await query.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<SimpleField> SaveAsync(SimpleFieldRequest req)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(req.CarType)) throw new SimpleFieldException("car_type 不能为空");
|
||||
if (string.IsNullOrWhiteSpace(req.FieldType)) throw new SimpleFieldException("field_type 不能为空");
|
||||
if (string.IsNullOrWhiteSpace(req.Key)) throw new SimpleFieldException("key 不能为空");
|
||||
|
||||
var carType = req.CarType.Trim();
|
||||
var now = SimpleFieldDateTime.Now;
|
||||
SimpleField entity;
|
||||
if (req.Id is { } id && id != Guid.Empty)
|
||||
{
|
||||
entity = await _db.SimpleFields.FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new SimpleFieldException("记录不存在");
|
||||
}
|
||||
else
|
||||
{
|
||||
var dup = await _db.SimpleFields.AnyAsync(x =>
|
||||
x.CarType == carType &&
|
||||
x.FieldType == req.FieldType.Trim() &&
|
||||
x.Key == req.Key.Trim());
|
||||
if (dup) throw new SimpleFieldException("同车型与字段类型下 key 已存在");
|
||||
|
||||
entity = new SimpleField { Id = Guid.NewGuid(), CreateTime = now };
|
||||
_db.SimpleFields.Add(entity);
|
||||
}
|
||||
|
||||
entity.CarType = carType;
|
||||
entity.FieldType = req.FieldType.Trim();
|
||||
entity.Key = req.Key.Trim();
|
||||
entity.Value = req.Value?.Trim() ?? "";
|
||||
entity.DataType = req.DataType?.Trim() ?? "";
|
||||
entity.Chinese = req.Chinese is null ? null : req.Chinese.Trim();
|
||||
entity.English = req.English is null ? null : req.English.Trim();
|
||||
entity.Other = req.Other?.Trim() ?? "";
|
||||
entity.IsDefault = req.IsDefault;
|
||||
entity.UpdateTime = now;
|
||||
if (entity.CreateTime == default) entity.CreateTime = now;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id)
|
||||
{
|
||||
var entity = await _db.SimpleFields.FirstOrDefaultAsync(x => x.Id == id)
|
||||
?? throw new SimpleFieldException("记录不存在");
|
||||
_db.SimpleFields.Remove(entity);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
/// <summary>批量保存全部字段;ReplaceAll=true 时清空表后写入。</summary>
|
||||
public async Task<int> SaveBatchAsync(SimpleFieldBatchRequest req)
|
||||
{
|
||||
var items = req.Items ?? new List<SimpleFieldRequest>();
|
||||
if (items.Count == 0) throw new SimpleFieldException("没有可保存的字段");
|
||||
|
||||
if (req.ReplaceAll)
|
||||
{
|
||||
var all = await _db.SimpleFields.ToListAsync();
|
||||
_db.SimpleFields.RemoveRange(all);
|
||||
}
|
||||
|
||||
var now = SimpleFieldDateTime.Now;
|
||||
var added = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(item.CarType) ||
|
||||
string.IsNullOrWhiteSpace(item.FieldType) ||
|
||||
string.IsNullOrWhiteSpace(item.Key))
|
||||
continue;
|
||||
|
||||
_db.SimpleFields.Add(new SimpleField
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CarType = item.CarType.Trim(),
|
||||
FieldType = item.FieldType.Trim(),
|
||||
Key = item.Key.Trim(),
|
||||
Value = item.Value?.Trim() ?? "",
|
||||
DataType = item.DataType?.Trim() ?? "",
|
||||
Chinese = item.Chinese is null ? null : item.Chinese.Trim(),
|
||||
English = item.English is null ? null : item.English.Trim(),
|
||||
Other = item.Other?.Trim() ?? "",
|
||||
IsDefault = item.IsDefault,
|
||||
CreateTime = now,
|
||||
UpdateTime = now
|
||||
});
|
||||
added++;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
return added;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SimpleFieldException : Exception
|
||||
{
|
||||
public SimpleFieldException(string message) : base(message) { }
|
||||
}
|
||||
Reference in New Issue
Block a user