Commit dbc9750fb7b2adcaf347d831a5fffa609682ee27

Authored by 李曜臣
1 parent 961eecae

成员模块实现

Showing 13 changed files with 795 additions and 1 deletions
label-template-template-1773988794039 (1).json 0 → 100644
  1 +{
  2 + "id": "template-1773988794039",
  3 + "name": "未命名模板",
  4 + "labelType": "PRICE",
  5 + "unit": "inch",
  6 + "width": 4,
  7 + "height": 6,
  8 + "appliedLocation": "ALL",
  9 + "showRuler": true,
  10 + "showGrid": true,
  11 + "elements": [
  12 + {
  13 + "id": "el-1773989351080-vqc03nr",
  14 + "type": "IMAGE",
  15 + "x": 32,
  16 + "y": 24,
  17 + "width": 60,
  18 + "height": 60,
  19 + "rotation": "horizontal",
  20 + "border": "none",
  21 + "config": {
  22 + "src": "",
  23 + "scaleMode": "contain"
  24 + }
  25 + },
  26 + {
  27 + "id": "el-1773989452538-0ejrxoe",
  28 + "type": "TEXT_STATIC",
  29 + "x": 32,
  30 + "y": 104,
  31 + "width": 120,
  32 + "height": 24,
  33 + "rotation": "horizontal",
  34 + "border": "none",
  35 + "config": {
  36 + "text": "文本",
  37 + "fontFamily": "Arial",
  38 + "fontSize": 14,
  39 + "fontWeight": "normal",
  40 + "textAlign": "left"
  41 + }
  42 + },
  43 + {
  44 + "id": "el-1773989466493-ibbroio",
  45 + "type": "QRCODE",
  46 + "x": 32,
  47 + "y": 136,
  48 + "width": 80,
  49 + "height": 80,
  50 + "rotation": "horizontal",
  51 + "border": "none",
  52 + "config": {
  53 + "data": "https://example.com",
  54 + "errorLevel": "M"
  55 + }
  56 + },
  57 + {
  58 + "id": "el-1773989469008-f1l39qj",
  59 + "type": "BARCODE",
  60 + "x": 0,
  61 + "y": 224,
  62 + "width": 160,
  63 + "height": 48,
  64 + "rotation": "horizontal",
  65 + "border": "none",
  66 + "config": {
  67 + "barcodeType": "CODE128",
  68 + "data": "123456789",
  69 + "showText": true,
  70 + "orientation": "horizontal"
  71 + }
  72 + },
  73 + {
  74 + "id": "el-1773989473436-j7fdeh2",
  75 + "type": "BLANK",
  76 + "x": 32,
  77 + "y": 288,
  78 + "width": 48,
  79 + "height": 32,
  80 + "rotation": "horizontal",
  81 + "border": "none",
  82 + "config": {}
  83 + },
  84 + {
  85 + "id": "el-1773989483341-ifwcyjj",
  86 + "type": "TEXT_PRICE",
  87 + "x": 152,
  88 + "y": 24,
  89 + "width": 80,
  90 + "height": 24,
  91 + "rotation": "horizontal",
  92 + "border": "none",
  93 + "config": {
  94 + "text": "0.00",
  95 + "prefix": "¥",
  96 + "decimal": 2,
  97 + "fontFamily": "Arial",
  98 + "fontSize": 14,
  99 + "fontWeight": "bold",
  100 + "textAlign": "right"
  101 + }
  102 + },
  103 + {
  104 + "id": "el-1773989498031-e4d61j8",
  105 + "type": "IMAGE",
  106 + "x": 192,
  107 + "y": 56,
  108 + "width": 60,
  109 + "height": 60,
  110 + "rotation": "horizontal",
  111 + "border": "none",
  112 + "config": {
  113 + "src": "",
  114 + "scaleMode": "contain"
  115 + }
  116 + },
  117 + {
  118 + "id": "el-1773989505076-1lxccx7",
  119 + "type": "TEXT_PRODUCT",
  120 + "x": 200,
  121 + "y": 136,
  122 + "width": 120,
  123 + "height": 24,
  124 + "rotation": "horizontal",
  125 + "border": "none",
  126 + "config": {
  127 + "text": "商品名",
  128 + "fontFamily": "Arial",
  129 + "fontSize": 14,
  130 + "fontWeight": "normal",
  131 + "textAlign": "left"
  132 + }
  133 + },
  134 + {
  135 + "id": "el-1773989509805-ax3392v",
  136 + "type": "TEXT_STATIC",
  137 + "x": 192,
  138 + "y": 160,
  139 + "width": 120,
  140 + "height": 24,
  141 + "rotation": "horizontal",
  142 + "border": "none",
  143 + "config": {
  144 + "text": "文本",
  145 + "fontFamily": "Arial",
  146 + "fontSize": 14,
  147 + "fontWeight": "normal",
  148 + "textAlign": "left"
  149 + }
  150 + },
  151 + {
  152 + "id": "el-1773989512993-xt8bg7q",
  153 + "type": "QRCODE",
  154 + "x": 184,
  155 + "y": 184,
  156 + "width": 80,
  157 + "height": 80,
  158 + "rotation": "horizontal",
  159 + "border": "none",
  160 + "config": {
  161 + "data": "https://example.com",
  162 + "errorLevel": "M"
  163 + }
  164 + },
  165 + {
  166 + "id": "el-1773989525383-eji8p2s",
  167 + "type": "BARCODE",
  168 + "x": 0,
  169 + "y": 288,
  170 + "width": 160,
  171 + "height": 48,
  172 + "rotation": "horizontal",
  173 + "border": "none",
  174 + "config": {
  175 + "barcodeType": "CODE128",
  176 + "data": "123456789",
  177 + "showText": true,
  178 + "orientation": "horizontal"
  179 + }
  180 + },
  181 + {
  182 + "id": "el-1773989540159-dr2avdf",
  183 + "type": "NUTRITION",
  184 + "x": 184,
  185 + "y": 280,
  186 + "width": 200,
  187 + "height": 120,
  188 + "rotation": "horizontal",
  189 + "border": "none",
  190 + "config": {
  191 + "calories": 120,
  192 + "fat": "5g",
  193 + "protein": "3g",
  194 + "carbs": "10g",
  195 + "layout": "standard"
  196 + }
  197 + },
  198 + {
  199 + "id": "el-1773989549679-mcxrdnw",
  200 + "type": "TEXT_PRICE",
  201 + "x": 24,
  202 + "y": 352,
  203 + "width": 80,
  204 + "height": 24,
  205 + "rotation": "horizontal",
  206 + "border": "none",
  207 + "config": {
  208 + "text": "0.00",
  209 + "prefix": "¥",
  210 + "decimal": 2,
  211 + "fontFamily": "Arial",
  212 + "fontSize": 14,
  213 + "fontWeight": "bold",
  214 + "textAlign": "right"
  215 + }
  216 + }
  217 + ]
  218 +}
0 219 \ No newline at end of file
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/RbacRole/RbacRoleGetOutputDto.cs
... ... @@ -5,5 +5,9 @@ namespace FoodLabeling.Application.Contracts.Dtos.RbacRole;
5 5 /// </summary>
6 6 public class RbacRoleGetOutputDto : RbacRoleGetListOutputDto
7 7 {
  8 + /// <summary>
  9 + /// 该角色已分配的菜单权限ID列表
  10 + /// </summary>
  11 + public List<string> MenuIds { get; set; } = new();
8 12 }
9 13  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberAssignedLocationDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberAssignedLocationDto
  4 +{
  5 + public string Id { get; set; } = string.Empty;
  6 +
  7 + public string LocationCode { get; set; } = string.Empty;
  8 +
  9 + public string LocationName { get; set; } = string.Empty;
  10 +}
  11 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberCreateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberCreateInputVo
  4 +{
  5 + public string FullName { get; set; } = string.Empty;
  6 +
  7 + /// <summary>
  8 + /// 登录账号(建议用邮箱或自定义用户名)
  9 + /// </summary>
  10 + public string UserName { get; set; } = string.Empty;
  11 +
  12 + public string Password { get; set; } = string.Empty;
  13 +
  14 + public string? Email { get; set; }
  15 +
  16 + public long? Phone { get; set; }
  17 +
  18 + public Guid? RoleId { get; set; }
  19 +
  20 + /// <summary>
  21 + /// 关联门店(至少1个)
  22 + /// </summary>
  23 + public List<string> LocationIds { get; set; } = new();
  24 +
  25 + public bool State { get; set; } = true;
  26 +}
  27 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetListInputVo.cs 0 → 100644
  1 +using Volo.Abp.Application.Dtos;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  4 +
  5 +/// <summary>
  6 +/// 成员分页查询入参
  7 +/// </summary>
  8 +public class TeamMemberGetListInputVo : PagedAndSortedResultRequestDto
  9 +{
  10 + /// <summary>
  11 + /// 关键字(姓名/用户名/邮箱/电话)
  12 + /// </summary>
  13 + public string? Keyword { get; set; }
  14 +
  15 + /// <summary>
  16 + /// 角色ID(可选)
  17 + /// </summary>
  18 + public Guid? RoleId { get; set; }
  19 +
  20 + /// <summary>
  21 + /// 门店ID(可选)
  22 + /// </summary>
  23 + public string? LocationId { get; set; }
  24 +
  25 + /// <summary>
  26 + /// 启用状态(可选)
  27 + /// </summary>
  28 + public bool? State { get; set; }
  29 +}
  30 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetListOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberGetListOutputDto
  4 +{
  5 + public Guid Id { get; set; }
  6 +
  7 + public string FullName { get; set; } = string.Empty;
  8 +
  9 + public string UserName { get; set; } = string.Empty;
  10 +
  11 + public string? Email { get; set; }
  12 +
  13 + public long? Phone { get; set; }
  14 +
  15 + public bool State { get; set; }
  16 +
  17 + /// <summary>
  18 + /// 角色(当前仅返回第一个)
  19 + /// </summary>
  20 + public Guid? RoleId { get; set; }
  21 +
  22 + public string? RoleName { get; set; }
  23 +
  24 + public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
  25 +}
  26 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberGetOutputDto.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberGetOutputDto
  4 +{
  5 + public Guid Id { get; set; }
  6 +
  7 + public string FullName { get; set; } = string.Empty;
  8 +
  9 + public string UserName { get; set; } = string.Empty;
  10 +
  11 + public string? Email { get; set; }
  12 +
  13 + public long? Phone { get; set; }
  14 +
  15 + public bool State { get; set; }
  16 +
  17 + public Guid? RoleId { get; set; }
  18 +
  19 + public List<string> LocationIds { get; set; } = new();
  20 +
  21 + public List<TeamMemberAssignedLocationDto> AssignedLocations { get; set; } = new();
  22 +}
  23 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/TeamMember/TeamMemberUpdateInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.TeamMember;
  2 +
  3 +public class TeamMemberUpdateInputVo
  4 +{
  5 + public string FullName { get; set; } = string.Empty;
  6 +
  7 + public string UserName { get; set; } = string.Empty;
  8 +
  9 + /// <summary>
  10 + /// 为空表示不改密码
  11 + /// </summary>
  12 + public string? Password { get; set; }
  13 +
  14 + public string? Email { get; set; }
  15 +
  16 + public long? Phone { get; set; }
  17 +
  18 + public Guid? RoleId { get; set; }
  19 +
  20 + public List<string> LocationIds { get; set; } = new();
  21 +
  22 + public bool State { get; set; } = true;
  23 +}
  24 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/ITeamMemberAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.TeamMember;
  3 +
  4 +namespace FoodLabeling.Application.Contracts.IServices;
  5 +
  6 +public interface ITeamMemberAppService
  7 +{
  8 + Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input);
  9 +
  10 + Task<TeamMemberGetOutputDto> GetAsync(Guid id);
  11 +
  12 + Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input);
  13 +
  14 + Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input);
  15 +
  16 + Task DeleteAsync(Guid id);
  17 +}
  18 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/RoleMenuDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// rolemenu 表映射(兼容字符串类型 RoleId/MenuId)
  7 +/// </summary>
  8 +[SugarTable("rolemenu")]
  9 +public class RoleMenuDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public string RoleId { get; set; } = string.Empty;
  15 +
  16 + public string MenuId { get; set; } = string.Empty;
  17 +}
  18 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/UserLocationDbEntity.cs 0 → 100644
  1 +using SqlSugar;
  2 +
  3 +namespace FoodLabeling.Application.Services.DbModels;
  4 +
  5 +/// <summary>
  6 +/// userlocation 表映射(成员-门店关联)
  7 +/// </summary>
  8 +[SugarTable("userlocation")]
  9 +public class UserLocationDbEntity
  10 +{
  11 + [SugarColumn(IsPrimaryKey = true)]
  12 + public string Id { get; set; } = string.Empty;
  13 +
  14 + public bool IsDeleted { get; set; }
  15 +
  16 + public DateTime CreationTime { get; set; }
  17 +
  18 + public string? CreatorId { get; set; }
  19 +
  20 + public string? LastModifierId { get; set; }
  21 +
  22 + public DateTime? LastModificationTime { get; set; }
  23 +
  24 + public string UserId { get; set; } = string.Empty;
  25 +
  26 + public string LocationId { get; set; } = string.Empty;
  27 +
  28 + public string? ConcurrencyStamp { get; set; }
  29 +}
  30 +
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/RbacRoleAppService.cs
1 1 using FoodLabeling.Application.Contracts.Dtos.RbacRole;
2 2 using FoodLabeling.Application.Contracts.Dtos.Common;
3 3 using FoodLabeling.Application.Contracts.IServices;
  4 +using FoodLabeling.Application.Services.DbModels;
4 5 using Microsoft.AspNetCore.Mvc;
5 6 using SqlSugar;
6 7 using Volo.Abp;
... ... @@ -17,17 +18,20 @@ namespace FoodLabeling.Application.Services;
17 18 /// </summary>
18 19 public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
19 20 {
  21 + private readonly ISqlSugarDbContext _dbContext;
20 22 private readonly ISqlSugarRepository<RoleAggregateRoot, Guid> _roleRepository;
21 23 private readonly ISqlSugarRepository<RoleMenuEntity> _roleMenuRepository;
22 24 private readonly ISqlSugarRepository<RoleDeptEntity> _roleDeptRepository;
23 25 private readonly ISqlSugarRepository<UserRoleEntity> _userRoleRepository;
24 26  
25 27 public RbacRoleAppService(
  28 + ISqlSugarDbContext dbContext,
26 29 ISqlSugarRepository<RoleAggregateRoot, Guid> roleRepository,
27 30 ISqlSugarRepository<RoleMenuEntity> roleMenuRepository,
28 31 ISqlSugarRepository<RoleDeptEntity> roleDeptRepository,
29 32 ISqlSugarRepository<UserRoleEntity> userRoleRepository)
30 33 {
  34 + _dbContext = dbContext;
31 35 _roleRepository = roleRepository;
32 36 _roleMenuRepository = roleMenuRepository;
33 37 _roleDeptRepository = roleDeptRepository;
... ... @@ -90,6 +94,11 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
90 94 throw new UserFriendlyException("角色不存在");
91 95 }
92 96  
  97 + var menuIds = await _dbContext.SqlSugarClient.Queryable<RoleMenuDbEntity>()
  98 + .Where(x => x.RoleId == id.ToString())
  99 + .Select(x => x.MenuId)
  100 + .ToListAsync();
  101 +
93 102 return new RbacRoleGetOutputDto
94 103 {
95 104 Id = entity.Id,
... ... @@ -98,7 +107,8 @@ public class RbacRoleAppService : ApplicationService, IRbacRoleAppService
98 107 Remark = entity.Remark,
99 108 DataScope = (int)entity.DataScope,
100 109 State = entity.State,
101   - OrderNum = entity.OrderNum
  110 + OrderNum = entity.OrderNum,
  111 + MenuIds = menuIds
102 112 };
103 113 }
104 114  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/TeamMemberAppService.cs 0 → 100644
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
  2 +using FoodLabeling.Application.Contracts.Dtos.TeamMember;
  3 +using FoodLabeling.Application.Contracts.IServices;
  4 +using FoodLabeling.Application.Services.DbModels;
  5 +using FoodLabeling.Domain.Entities;
  6 +using SqlSugar;
  7 +using Volo.Abp;
  8 +using Volo.Abp.Application.Services;
  9 +using Volo.Abp.Domain.Entities;
  10 +using Volo.Abp.Guids;
  11 +using Yi.Framework.Rbac.Domain.Entities;
  12 +using Yi.Framework.Rbac.Domain.Managers;
  13 +using Yi.Framework.SqlSugarCore.Abstractions;
  14 +
  15 +namespace FoodLabeling.Application.Services;
  16 +
  17 +/// <summary>
  18 +/// 成员(Team Member/Manager)服务,对外仅在 food-labeling-us 暴露
  19 +/// </summary>
  20 +public class TeamMemberAppService : ApplicationService, ITeamMemberAppService
  21 +{
  22 + private readonly ISqlSugarRepository<UserAggregateRoot, Guid> _userRepository;
  23 + private readonly UserManager _userManager;
  24 + private readonly ISqlSugarDbContext _dbContext;
  25 + private readonly IGuidGenerator _guidGenerator;
  26 +
  27 + public TeamMemberAppService(
  28 + ISqlSugarRepository<UserAggregateRoot, Guid> userRepository,
  29 + UserManager userManager,
  30 + ISqlSugarDbContext dbContext,
  31 + IGuidGenerator guidGenerator)
  32 + {
  33 + _userRepository = userRepository;
  34 + _userManager = userManager;
  35 + _dbContext = dbContext;
  36 + _guidGenerator = guidGenerator;
  37 + }
  38 +
  39 + /// <summary>
  40 + /// 成员分页列表(含角色与已分配门店)
  41 + /// </summary>
  42 + public async Task<PagedResultWithPageDto<TeamMemberGetListOutputDto>> GetListAsync(TeamMemberGetListInputVo input)
  43 + {
  44 + var pageIndex = input.SkipCount / input.MaxResultCount + 1;
  45 + var pageSize = input.MaxResultCount;
  46 + var keyword = input.Keyword?.Trim();
  47 +
  48 + RefAsync<int> total = 0;
  49 +
  50 + // 先按 user 表筛选分页,再批量补齐角色与门店
  51 + var users = await _userRepository._DbQueryable
  52 + .Where(u => !u.IsDeleted)
  53 + .WhereIF(!string.IsNullOrWhiteSpace(keyword),
  54 + u => (u.Name != null && u.Name.Contains(keyword!)) ||
  55 + u.UserName.Contains(keyword!) ||
  56 + (u.Email != null && u.Email.Contains(keyword!)) ||
  57 + (u.Phone != null && u.Phone.ToString()!.Contains(keyword!)))
  58 + .WhereIF(input.State != null, u => u.State == input.State)
  59 + .OrderByIF(!string.IsNullOrWhiteSpace(input.Sorting), input.Sorting!)
  60 + .OrderByDescending(u => u.CreationTime)
  61 + .ToPageListAsync(input.SkipCount, input.MaxResultCount, total);
  62 +
  63 + var userIds = users.Select(x => x.Id).ToList();
  64 + var userIdStrings = userIds.Select(x => x.ToString()).ToList();
  65 +
  66 + // user-role: 仅取第一个角色(原型表格展示单角色)
  67 + var userRolePairs = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity, RoleAggregateRoot>((ur, r) => ur.RoleId == r.Id)
  68 + .Where(ur => userIds.Contains(ur.UserId))
  69 + .Select((ur, r) => new { ur.UserId, r.Id, r.RoleName })
  70 + .ToListAsync();
  71 +
  72 + var roleMap = userRolePairs
  73 + .GroupBy(x => x.UserId)
  74 + .ToDictionary(g => g.Key, g => g.FirstOrDefault());
  75 +
  76 + // user-location
  77 + var userLocations = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  78 + .Where(x => !x.IsDeleted)
  79 + .WhereIF(!string.IsNullOrWhiteSpace(input.LocationId), x => x.LocationId == input.LocationId)
  80 + .Where(x => userIdStrings.Contains(x.UserId))
  81 + .ToListAsync();
  82 +
  83 + // 如果按 LocationId 过滤,需要反向过滤掉无关联的 user
  84 + if (!string.IsNullOrWhiteSpace(input.LocationId))
  85 + {
  86 + var allowedUserIds = userLocations.Select(x => x.UserId).ToHashSet();
  87 + users = users.Where(u => allowedUserIds.Contains(u.Id.ToString())).ToList();
  88 + }
  89 +
  90 + var locationIds = userLocations.Select(x => x.LocationId).Distinct().ToList();
  91 + var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  92 + .Where(x => !x.IsDeleted)
  93 + .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
  94 + .Select(x => new { x.Id, x.LocationCode, x.LocationName })
  95 + .ToListAsync();
  96 + var locationMap = locations.ToDictionary(x => x.Id.ToString(), x => x);
  97 +
  98 + var assignedMap = userLocations
  99 + .GroupBy(x => x.UserId)
  100 + .ToDictionary(
  101 + g => g.Key,
  102 + g => g.Select(x =>
  103 + {
  104 + if (locationMap.TryGetValue(x.LocationId, out var loc))
  105 + {
  106 + return new TeamMemberAssignedLocationDto
  107 + {
  108 + Id = loc.Id.ToString(),
  109 + LocationCode = loc.LocationCode,
  110 + LocationName = loc.LocationName
  111 + };
  112 + }
  113 + return null;
  114 + }).Where(x => x != null).Cast<TeamMemberAssignedLocationDto>().ToList());
  115 +
  116 + var items = users.Select(u =>
  117 + {
  118 + roleMap.TryGetValue(u.Id, out var role);
  119 + assignedMap.TryGetValue(u.Id.ToString(), out var assigned);
  120 +
  121 + return new TeamMemberGetListOutputDto
  122 + {
  123 + Id = u.Id,
  124 + FullName = u.Name ?? string.Empty,
  125 + UserName = u.UserName,
  126 + Email = u.Email,
  127 + Phone = u.Phone,
  128 + State = u.State,
  129 + RoleId = role?.Id,
  130 + RoleName = role?.RoleName,
  131 + AssignedLocations = assigned ?? new List<TeamMemberAssignedLocationDto>()
  132 + };
  133 + }).ToList();
  134 +
  135 + var totalCount = (long)total;
  136 + return new PagedResultWithPageDto<TeamMemberGetListOutputDto>
  137 + {
  138 + PageIndex = pageIndex,
  139 + PageSize = pageSize,
  140 + TotalCount = totalCount,
  141 + TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize),
  142 + Items = items
  143 + };
  144 + }
  145 +
  146 + /// <summary>
  147 + /// 成员详情(带门店ID列表)
  148 + /// </summary>
  149 + public async Task<TeamMemberGetOutputDto> GetAsync(Guid id)
  150 + {
  151 + var user = await _userRepository.GetByIdAsync(id);
  152 + if (user is null || user.IsDeleted)
  153 + {
  154 + throw new UserFriendlyException("成员不存在");
  155 + }
  156 +
  157 + var userIdString = id.ToString();
  158 + var links = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  159 + .Where(x => !x.IsDeleted && x.UserId == userIdString)
  160 + .ToListAsync();
  161 +
  162 + var locationIds = links.Select(x => x.LocationId).Distinct().ToList();
  163 + var locations = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  164 + .Where(x => !x.IsDeleted)
  165 + .WhereIF(locationIds.Count > 0, x => locationIds.Contains(x.Id.ToString()))
  166 + .Select(x => new { x.Id, x.LocationCode, x.LocationName })
  167 + .ToListAsync();
  168 +
  169 + var assigned = locations.Select(x => new TeamMemberAssignedLocationDto
  170 + {
  171 + Id = x.Id.ToString(),
  172 + LocationCode = x.LocationCode,
  173 + LocationName = x.LocationName
  174 + }).ToList();
  175 +
  176 + var role = await _dbContext.SqlSugarClient.Queryable<UserRoleEntity>().FirstAsync(x => x.UserId == id);
  177 +
  178 + return new TeamMemberGetOutputDto
  179 + {
  180 + Id = user.Id,
  181 + FullName = user.Name ?? string.Empty,
  182 + UserName = user.UserName,
  183 + Email = user.Email,
  184 + Phone = user.Phone,
  185 + State = user.State,
  186 + RoleId = role?.RoleId,
  187 + LocationIds = locationIds,
  188 + AssignedLocations = assigned
  189 + };
  190 + }
  191 +
  192 + /// <summary>
  193 + /// 新增成员(同步设置角色与门店)
  194 + /// </summary>
  195 + public async Task<TeamMemberGetOutputDto> CreateAsync(TeamMemberCreateInputVo input)
  196 + {
  197 + if (input.LocationIds is null || input.LocationIds.Count == 0)
  198 + {
  199 + throw new UserFriendlyException("成员必须至少分配一个门店");
  200 + }
  201 +
  202 + var user = new UserAggregateRoot(input.UserName.Trim(), input.Password, input.Phone, input.FullName.Trim())
  203 + {
  204 + Name = input.FullName.Trim(),
  205 + Email = input.Email?.Trim(),
  206 + State = input.State
  207 + };
  208 +
  209 + EntityHelper.TrySetId(user, _guidGenerator.Create);
  210 + user.BuildPassword();
  211 +
  212 + await _userManager.CreateAsync(user);
  213 +
  214 + if (input.RoleId != null)
  215 + {
  216 + await _userManager.GiveUserSetRoleAsync(new List<Guid> { user.Id }, new List<Guid> { input.RoleId.Value });
  217 + }
  218 +
  219 + await UpsertUserLocationsAsync(user.Id, input.LocationIds);
  220 +
  221 + return await GetAsync(user.Id);
  222 + }
  223 +
  224 + /// <summary>
  225 + /// 编辑成员(同步设置角色与门店)
  226 + /// </summary>
  227 + public async Task<TeamMemberGetOutputDto> UpdateAsync(Guid id, TeamMemberUpdateInputVo input)
  228 + {
  229 + if (input.LocationIds is null || input.LocationIds.Count == 0)
  230 + {
  231 + throw new UserFriendlyException("成员必须至少分配一个门店");
  232 + }
  233 +
  234 + var user = await _userRepository.GetByIdAsync(id);
  235 + if (user is null || user.IsDeleted)
  236 + {
  237 + throw new UserFriendlyException("成员不存在");
  238 + }
  239 +
  240 + user.Name = input.FullName.Trim();
  241 + user.UserName = input.UserName.Trim();
  242 + user.Email = input.Email?.Trim();
  243 + user.Phone = input.Phone;
  244 + user.State = input.State;
  245 +
  246 + if (!string.IsNullOrWhiteSpace(input.Password))
  247 + {
  248 + user.EncryPassword.Password = input.Password;
  249 + user.BuildPassword();
  250 + }
  251 +
  252 + await _userRepository.UpdateAsync(user);
  253 +
  254 + // 角色:覆盖式设置(只保留一个)
  255 + if (input.RoleId != null)
  256 + {
  257 + await _userManager.GiveUserSetRoleAsync(new List<Guid> { id }, new List<Guid> { input.RoleId.Value });
  258 + }
  259 + else
  260 + {
  261 + await _userManager.GiveUserSetRoleAsync(new List<Guid> { id }, new List<Guid>());
  262 + }
  263 +
  264 + await UpsertUserLocationsAsync(id, input.LocationIds);
  265 +
  266 + return await GetAsync(id);
  267 + }
  268 +
  269 + /// <summary>
  270 + /// 删除成员(逻辑删除 user;并逻辑删除关联表)
  271 + /// </summary>
  272 + public async Task DeleteAsync(Guid id)
  273 + {
  274 + var user = await _userRepository.GetByIdAsync(id);
  275 + if (user is null || user.IsDeleted)
  276 + {
  277 + return;
  278 + }
  279 +
  280 + user.IsDeleted = true;
  281 + await _userRepository.UpdateAsync(user);
  282 +
  283 + var userIdString = id.ToString();
  284 + var currentUserId = CurrentUser?.Id?.ToString();
  285 + await _dbContext.SqlSugarClient.Updateable<UserLocationDbEntity>()
  286 + .SetColumns(x => new UserLocationDbEntity
  287 + {
  288 + IsDeleted = true,
  289 + LastModificationTime = DateTime.Now,
  290 + LastModifierId = currentUserId
  291 + })
  292 + .Where(x => x.UserId == userIdString && !x.IsDeleted)
  293 + .ExecuteCommandAsync();
  294 + }
  295 +
  296 + private async Task UpsertUserLocationsAsync(Guid userId, List<string> locationIds)
  297 + {
  298 + var now = DateTime.Now;
  299 + var userIdString = userId.ToString();
  300 + var wanted = locationIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct().ToList();
  301 + var currentUserId = CurrentUser?.Id?.ToString();
  302 +
  303 + // 校验门店存在且未删除
  304 + var validCount = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  305 + .Where(x => !x.IsDeleted)
  306 + .Where(x => wanted.Contains(x.Id.ToString()))
  307 + .CountAsync();
  308 + if (validCount != wanted.Count)
  309 + {
  310 + throw new UserFriendlyException("存在无效门店,请刷新后重试");
  311 + }
  312 +
  313 + var existing = await _dbContext.SqlSugarClient.Queryable<UserLocationDbEntity>()
  314 + .Where(x => x.UserId == userIdString)
  315 + .ToListAsync();
  316 +
  317 + var existingActive = existing.Where(x => !x.IsDeleted).ToList();
  318 + var existingActiveSet = existingActive.Select(x => x.LocationId).ToHashSet();
  319 +
  320 + // 需要删除的(逻辑删除)
  321 + var toDelete = existingActive.Where(x => !wanted.Contains(x.LocationId)).ToList();
  322 + if (toDelete.Count > 0)
  323 + {
  324 + var ids = toDelete.Select(x => x.Id).ToList();
  325 + await _dbContext.SqlSugarClient.Updateable<UserLocationDbEntity>()
  326 + .SetColumns(x => new UserLocationDbEntity
  327 + {
  328 + IsDeleted = true,
  329 + LastModificationTime = now,
  330 + LastModifierId = currentUserId
  331 + })
  332 + .Where(x => ids.Contains(x.Id))
  333 + .ExecuteCommandAsync();
  334 + }
  335 +
  336 + // 需要新增的
  337 + var toInsert = wanted.Where(x => !existingActiveSet.Contains(x)).ToList();
  338 + if (toInsert.Count > 0)
  339 + {
  340 + var rows = toInsert.Select(locationId => new UserLocationDbEntity
  341 + {
  342 + Id = _guidGenerator.Create().ToString(),
  343 + IsDeleted = false,
  344 + CreationTime = now,
  345 + CreatorId = currentUserId,
  346 + UserId = userIdString,
  347 + LocationId = locationId,
  348 + ConcurrencyStamp = string.Empty
  349 + }).ToList();
  350 +
  351 + await _dbContext.SqlSugarClient.Insertable(rows).ExecuteCommandAsync();
  352 + }
  353 + }
  354 +}
  355 +
... ...