feat: add admin menu management crud

This commit is contained in:
2025-12-05 21:16:07 +08:00
parent 02e33de5c8
commit a1499fc1a1
22 changed files with 1747 additions and 2 deletions

View File

@@ -0,0 +1,26 @@
using MediatR;
using System.Collections.Generic;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 创建菜单命令。
/// </summary>
public sealed record CreateMenuCommand : IRequest<MenuDefinitionDto>
{
public long ParentId { get; init; }
public string Name { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public string Component { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string? Icon { get; init; }
public bool IsIframe { get; init; }
public string? Link { get; init; }
public bool KeepAlive { get; init; }
public int SortOrder { get; init; }
public IReadOnlyCollection<string> RequiredPermissions { get; init; } = [];
public IReadOnlyCollection<string> MetaPermissions { get; init; } = [];
public IReadOnlyCollection<string> MetaRoles { get; init; } = [];
public IReadOnlyCollection<MenuAuthItemDto> AuthList { get; init; } = [];
}

View File

@@ -0,0 +1,11 @@
using MediatR;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 删除菜单命令。
/// </summary>
public sealed record DeleteMenuCommand : IRequest<bool>
{
public long Id { get; init; }
}

View File

@@ -0,0 +1,27 @@
using MediatR;
using System.Collections.Generic;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Commands;
/// <summary>
/// 更新菜单命令。
/// </summary>
public sealed record UpdateMenuCommand : IRequest<MenuDefinitionDto?>
{
public long Id { get; init; }
public long ParentId { get; init; }
public string Name { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public string Component { get; init; } = string.Empty;
public string Title { get; init; } = string.Empty;
public string? Icon { get; init; }
public bool IsIframe { get; init; }
public string? Link { get; init; }
public bool KeepAlive { get; init; }
public int SortOrder { get; init; }
public IReadOnlyCollection<string> RequiredPermissions { get; init; } = [];
public IReadOnlyCollection<string> MetaPermissions { get; init; } = [];
public IReadOnlyCollection<string> MetaRoles { get; init; } = [];
public IReadOnlyCollection<MenuAuthItemDto> AuthList { get; init; } = [];
}

View File

@@ -0,0 +1,84 @@
using System.Collections.Generic;
namespace TakeoutSaaS.Application.Identity.Contracts;
/// <summary>
/// 菜单定义 DTO。
/// </summary>
public sealed record MenuDefinitionDto
{
/// <summary>
/// 菜单 ID。
/// </summary>
public long Id { get; init; }
/// <summary>
/// 父级菜单 ID。
/// </summary>
public long ParentId { get; init; }
/// <summary>
/// 名称(路由 name
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// 路由路径。
/// </summary>
public string Path { get; init; } = string.Empty;
/// <summary>
/// 组件路径。
/// </summary>
public string Component { get; init; } = string.Empty;
/// <summary>
/// 标题。
/// </summary>
public string Title { get; init; } = string.Empty;
/// <summary>
/// 图标。
/// </summary>
public string? Icon { get; init; }
/// <summary>
/// 是否 iframe。
/// </summary>
public bool IsIframe { get; init; }
/// <summary>
/// 链接。
/// </summary>
public string? Link { get; init; }
/// <summary>
/// 是否缓存。
/// </summary>
public bool KeepAlive { get; init; }
/// <summary>
/// 排序。
/// </summary>
public int SortOrder { get; init; }
/// <summary>
/// 访问权限。
/// </summary>
public IReadOnlyList<string> RequiredPermissions { get; init; } = [];
/// <summary>
/// Meta.permissions。
/// </summary>
public IReadOnlyList<string> MetaPermissions { get; init; } = [];
/// <summary>
/// Meta.roles。
/// </summary>
public IReadOnlyList<string> MetaRoles { get; init; } = [];
/// <summary>
/// 按钮权限列表。
/// </summary>
public IReadOnlyList<MenuAuthItemDto> AuthList { get; init; } = [];
}

View File

@@ -0,0 +1,51 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 创建菜单处理器。
/// </summary>
public sealed class CreateMenuCommandHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
: IRequestHandler<CreateMenuCommand, MenuDefinitionDto>
{
/// <inheritdoc />
public async Task<MenuDefinitionDto> Handle(CreateMenuCommand request, CancellationToken cancellationToken)
{
// 1. 构造实体
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = new MenuDefinition
{
TenantId = tenantId,
ParentId = request.ParentId,
Name = request.Name.Trim(),
Path = request.Path.Trim(),
Component = request.Component.Trim(),
Title = request.Title.Trim(),
Icon = request.Icon?.Trim(),
IsIframe = request.IsIframe,
Link = string.IsNullOrWhiteSpace(request.Link) ? null : request.Link.Trim(),
KeepAlive = request.KeepAlive,
SortOrder = request.SortOrder,
RequiredPermissions = MenuMapper.JoinCodes(request.RequiredPermissions),
MetaPermissions = MenuMapper.JoinCodes(request.MetaPermissions),
MetaRoles = MenuMapper.JoinCodes(request.MetaRoles),
AuthListJson = request.AuthList.Count == 0
? null
: System.Text.Json.JsonSerializer.Serialize(request.AuthList)
};
// 2. 持久化
await menuRepository.AddAsync(entity, cancellationToken);
await menuRepository.SaveChangesAsync(cancellationToken);
// 3. 返回 DTO
return MenuMapper.ToDto(entity);
}
}

View File

@@ -0,0 +1,29 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 删除菜单处理器。
/// </summary>
public sealed class DeleteMenuCommandHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
: IRequestHandler<DeleteMenuCommand, bool>
{
/// <inheritdoc />
public async Task<bool> Handle(DeleteMenuCommand request, CancellationToken cancellationToken)
{
// 1. 删除目标及可能的孤儿由外层保证
var tenantId = tenantProvider.GetCurrentTenantId();
await menuRepository.DeleteAsync(request.Id, tenantId, cancellationToken);
// 2. 持久化
await menuRepository.SaveChangesAsync(cancellationToken);
// 3. 返回执行结果
return true;
}
}

View File

@@ -0,0 +1,32 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 菜单列表查询处理器。
/// </summary>
public sealed class ListMenusQueryHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
: IRequestHandler<ListMenusQuery, IReadOnlyList<MenuDefinitionDto>>
{
/// <inheritdoc />
public async Task<IReadOnlyList<MenuDefinitionDto>> Handle(ListMenusQuery request, CancellationToken cancellationToken)
{
// 1. 获取租户
var tenantId = tenantProvider.GetCurrentTenantId();
// 2. 查询列表
var entities = await menuRepository.GetByTenantAsync(tenantId, cancellationToken);
// 3. 映射 DTO
var items = entities.Select(MenuMapper.ToDto).ToList();
// 4. 返回结果
return items;
}
}

View File

@@ -0,0 +1,33 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Application.Identity.Queries;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 菜单详情查询处理器。
/// </summary>
public sealed class MenuDetailQueryHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
: IRequestHandler<MenuDetailQuery, MenuDefinitionDto?>
{
/// <inheritdoc />
public async Task<MenuDefinitionDto?> Handle(MenuDetailQuery request, CancellationToken cancellationToken)
{
// 1. 获取租户
var tenantId = tenantProvider.GetCurrentTenantId();
// 2. 查询实体
var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken);
if (entity is null)
{
return null;
}
// 3. 映射并返回
return MenuMapper.ToDto(entity);
}
}

View File

@@ -0,0 +1,101 @@
using System.Text.Json;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Entities;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 菜单映射辅助。
/// </summary>
internal static class MenuMapper
{
public static MenuDefinitionDto ToDto(MenuDefinition entity)
{
// 1. 解析权限字段
var requiredPermissions = SplitCodes(entity.RequiredPermissions);
var metaPermissions = SplitCodes(entity.MetaPermissions);
var metaRoles = SplitCodes(entity.MetaRoles);
// 2. 解析按钮权限
var authList = string.IsNullOrWhiteSpace(entity.AuthListJson)
? []
: JsonSerializer.Deserialize<List<MenuAuthItemDto>>(entity.AuthListJson) ?? [];
// 3. 输出 DTO
return new MenuDefinitionDto
{
Id = entity.Id,
ParentId = entity.ParentId,
Name = entity.Name,
Path = entity.Path,
Component = entity.Component,
Title = entity.Title,
Icon = entity.Icon,
IsIframe = entity.IsIframe,
Link = entity.Link,
KeepAlive = entity.KeepAlive,
SortOrder = entity.SortOrder,
RequiredPermissions = requiredPermissions,
MetaPermissions = metaPermissions,
MetaRoles = metaRoles,
AuthList = authList
};
}
public static void FillEntity(MenuDefinition entity, MenuDefinitionDto dto)
{
// 1. 赋值基础字段
entity.ParentId = dto.ParentId;
entity.Name = dto.Name;
entity.Path = dto.Path;
entity.Component = dto.Component;
entity.Title = dto.Title;
entity.Icon = dto.Icon;
entity.IsIframe = dto.IsIframe;
entity.Link = dto.Link;
entity.KeepAlive = dto.KeepAlive;
entity.SortOrder = dto.SortOrder;
// 2. 权限与按钮
entity.RequiredPermissions = JoinCodes(dto.RequiredPermissions);
entity.MetaPermissions = JoinCodes(dto.MetaPermissions);
entity.MetaRoles = JoinCodes(dto.MetaRoles);
entity.AuthListJson = dto.AuthList.Count == 0
? null
: JsonSerializer.Serialize(dto.AuthList);
}
public static MenuDefinitionDto FromCommand(MenuDefinition? existing, long tenantId, string name, MenuDefinitionDto payload)
{
// 1. 构造实体
var entity = existing ?? new MenuDefinition
{
TenantId = tenantId,
CreatedAt = DateTime.UtcNow
};
// // 填充字段
FillEntity(entity, payload);
// 2. 返回 DTO 映射
return ToDto(entity);
}
public static string JoinCodes(IEnumerable<string> codes)
{
return string.Join(',', codes.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.OrdinalIgnoreCase));
}
public static string[] SplitCodes(string? codes)
{
if (string.IsNullOrWhiteSpace(codes))
{
return Array.Empty<string>();
}
return codes
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}

View File

@@ -0,0 +1,52 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Commands;
using TakeoutSaaS.Application.Identity.Contracts;
using TakeoutSaaS.Domain.Identity.Repositories;
using TakeoutSaaS.Shared.Abstractions.Constants;
using TakeoutSaaS.Shared.Abstractions.Exceptions;
using TakeoutSaaS.Shared.Abstractions.Tenancy;
namespace TakeoutSaaS.Application.Identity.Handlers;
/// <summary>
/// 更新菜单处理器。
/// </summary>
public sealed class UpdateMenuCommandHandler(
IMenuRepository menuRepository,
ITenantProvider tenantProvider)
: IRequestHandler<UpdateMenuCommand, MenuDefinitionDto?>
{
/// <inheritdoc />
public async Task<MenuDefinitionDto?> Handle(UpdateMenuCommand request, CancellationToken cancellationToken)
{
// 1. 校验存在
var tenantId = tenantProvider.GetCurrentTenantId();
var entity = await menuRepository.FindByIdAsync(request.Id, tenantId, cancellationToken)
?? throw new BusinessException(ErrorCodes.NotFound, "菜单不存在");
// 2. 更新字段
entity.ParentId = request.ParentId;
entity.Name = request.Name.Trim();
entity.Path = request.Path.Trim();
entity.Component = request.Component.Trim();
entity.Title = request.Title.Trim();
entity.Icon = request.Icon?.Trim();
entity.IsIframe = request.IsIframe;
entity.Link = string.IsNullOrWhiteSpace(request.Link) ? null : request.Link.Trim();
entity.KeepAlive = request.KeepAlive;
entity.SortOrder = request.SortOrder;
entity.RequiredPermissions = MenuMapper.JoinCodes(request.RequiredPermissions);
entity.MetaPermissions = MenuMapper.JoinCodes(request.MetaPermissions);
entity.MetaRoles = MenuMapper.JoinCodes(request.MetaRoles);
entity.AuthListJson = request.AuthList.Count == 0
? null
: System.Text.Json.JsonSerializer.Serialize(request.AuthList);
// 3. 持久化
await menuRepository.UpdateAsync(entity, cancellationToken);
await menuRepository.SaveChangesAsync(cancellationToken);
// 4. 返回 DTO
return MenuMapper.ToDto(entity);
}
}

View File

@@ -0,0 +1,12 @@
using MediatR;
using System.Collections.Generic;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 查询菜单列表(当前租户)。
/// </summary>
public sealed class ListMenusQuery : IRequest<IReadOnlyList<MenuDefinitionDto>>
{
}

View File

@@ -0,0 +1,15 @@
using MediatR;
using TakeoutSaaS.Application.Identity.Contracts;
namespace TakeoutSaaS.Application.Identity.Queries;
/// <summary>
/// 菜单详情查询。
/// </summary>
public sealed class MenuDetailQuery : IRequest<MenuDefinitionDto?>
{
/// <summary>
/// 菜单 ID。
/// </summary>
public long Id { get; init; }
}

View File

@@ -19,6 +19,7 @@ public sealed class AdminAuthService(
IRoleRepository roleRepository,
IPermissionRepository permissionRepository,
IRolePermissionRepository rolePermissionRepository,
IMenuRepository menuRepository,
IPasswordHasher<IdentityUser> passwordHasher,
IJwtTokenService jwtTokenService,
IRefreshTokenStore refreshTokenStore,
@@ -29,6 +30,7 @@ public sealed class AdminAuthService(
private readonly IRoleRepository _roleRepository = roleRepository;
private readonly IPermissionRepository _permissionRepository = permissionRepository;
private readonly IRolePermissionRepository _rolePermissionRepository = rolePermissionRepository;
private readonly IMenuRepository _menuRepository = menuRepository;
/// <summary>
/// 管理后台登录:验证账号密码并生成令牌。
@@ -108,8 +110,12 @@ public sealed class AdminAuthService(
{
// 1. 读取档案以获取权限
var profile = await GetProfileAsync(userId, cancellationToken);
// 2. 生成菜单树
var menu = AdminMenuProvider.BuildMenuTree(profile.Permissions);
// 2. 读取菜单定义
var tenantId = _tenantProvider.GetCurrentTenantId();
var definitions = await _menuRepository.GetByTenantAsync(tenantId, cancellationToken);
// 3. 生成菜单树
var menu = BuildMenuTree(definitions, profile.Permissions);
return menu;
}
@@ -208,6 +214,103 @@ public sealed class AdminAuthService(
};
}
private static IReadOnlyList<MenuNodeDto> BuildMenuTree(
IReadOnlyList<Domain.Identity.Entities.MenuDefinition> definitions,
IReadOnlyList<string> permissions)
{
// 1. 权限集合
var permissionSet = new HashSet<string>(permissions ?? [], StringComparer.OrdinalIgnoreCase);
// 2. 父子分组
var parentLookup = definitions
.GroupBy(x => x.ParentId)
.ToDictionary(g => g.Key, g => g.OrderBy(d => d.SortOrder).ToList());
// 3. 递归构造并过滤
IReadOnlyList<MenuNodeDto> BuildChildren(long parentId)
{
if (!parentLookup.TryGetValue(parentId, out var children))
{
return [];
}
// 1. 收集可见节点
var result = new List<MenuNodeDto>();
foreach (var def in children)
{
// 1.1 构造节点
var node = MapMenuNode(def);
// 1.2 递归子节点
var sub = BuildChildren(def.Id);
// 1.3 可见性
var required = node.RequiredPermissions ?? [];
var visible = required.Length == 0 || required.Any(permissionSet.Contains);
// 1.4 收集
if (visible || sub.Count > 0)
{
result.Add(node with { Children = sub });
}
}
// 2. 返回本层节点
return result;
}
// 4. 返回根节点
return BuildChildren(0);
}
private static MenuNodeDto MapMenuNode(Domain.Identity.Entities.MenuDefinition definition)
{
// 1. 解析权限
var requiredPermissions = SplitCodes(definition.RequiredPermissions);
var metaPermissions = SplitCodes(definition.MetaPermissions);
var metaRoles = SplitCodes(definition.MetaRoles);
// 2. 解析按钮权限
var authList = string.IsNullOrWhiteSpace(definition.AuthListJson)
? []
: System.Text.Json.JsonSerializer.Deserialize<List<MenuAuthItemDto>>(definition.AuthListJson) ?? [];
// 3. 构造节点
return new MenuNodeDto
{
Name = definition.Name,
Path = definition.Path,
Component = definition.Component,
Meta = new MenuMetaDto
{
Title = definition.Title,
Icon = definition.Icon,
KeepAlive = definition.KeepAlive,
IsIframe = definition.IsIframe,
Link = definition.Link,
Roles = metaRoles,
Permissions = metaPermissions,
AuthList = authList
},
Children = [],
RequiredPermissions = requiredPermissions
};
}
private static string[] SplitCodes(string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return Array.Empty<string>();
}
// 1. 拆分去重
return value
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
private async Task<string[]> ResolveUserRolesAsync(long tenantId, long userId, CancellationToken cancellationToken)
{
var relations = await _userRoleRepository.GetByUserIdAsync(tenantId, userId, cancellationToken);