9 changed files with 728 additions and 3 deletions
@ -1,6 +1,19 @@ |
|||
{ |
|||
"culture": "en", |
|||
"texts": { |
|||
"Required": "Required" |
|||
"Required": "Required", |
|||
"InvalidSelectAccountRequest": "Invalid request for a selected account!", |
|||
"NoAvailableAccounts": "No Available Accounts!", |
|||
"InvalidSelectAccount": "Invalid Select Account!", |
|||
"SelectAccount": "Select Account", |
|||
"CurrentAccount": "Current", |
|||
"SignedInAs": "Select to login to the account of {0}", |
|||
"LastLoginTime": "Last Login", |
|||
"Continue": "Continue", |
|||
"SwitchToAnotherAccount": "Switch to another account", |
|||
"CreateNewAccount": "Create a new account", |
|||
"SelectedAccountId": "Selected a cccount", |
|||
"RememberSelection": "Remember selection", |
|||
"RememberMe": "Remember me" |
|||
} |
|||
} |
|||
@ -1,6 +1,19 @@ |
|||
{ |
|||
"culture": "zh-Hans", |
|||
"texts": { |
|||
"Required": "必须" |
|||
"Required": "必须", |
|||
"InvalidSelectAccountRequest": "无效的选择账户请求!", |
|||
"NoAvailableAccounts": "未找到可用账户!", |
|||
"InvalidSelectAccount": "选择的账户无效!", |
|||
"SelectAccount": "选择账户", |
|||
"CurrentAccount": "当前账户", |
|||
"SignedInAs": "选择登录到 {0} 的账户", |
|||
"LastLoginTime": "上次登录", |
|||
"Continue": "继续", |
|||
"SwitchToAnotherAccount": "使用其他账户", |
|||
"CreateNewAccount": "创建新账户", |
|||
"SelectedAccountId": "请选择一个账户", |
|||
"RememberSelection": "记住我的选择", |
|||
"RememberMe": "保持登录状态" |
|||
} |
|||
} |
|||
@ -0,0 +1,133 @@ |
|||
@page |
|||
@using Microsoft.AspNetCore.Mvc.Localization |
|||
@using Volo.Abp.AspNetCore.Mvc.UI.Layout |
|||
@using Volo.Abp.AspNetCore.Mvc.UI.Theming; |
|||
@using Volo.Abp.OpenIddict.Localization |
|||
@using LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account |
|||
@inject IThemeManager ThemeManager |
|||
@inject IPageLayout PageLayout |
|||
@inject IHtmlLocalizer<AbpOpenIddictResource> L |
|||
@model SelectAccountModel |
|||
|
|||
@{ |
|||
Layout = ThemeManager.CurrentTheme.GetAccountLayout(); |
|||
PageLayout.Content.Title = L["SelectAccount"].Value; |
|||
} |
|||
|
|||
@section styles |
|||
{ |
|||
<abp-style-bundle name="@typeof(SelectAccountModel).FullName" /> |
|||
} |
|||
|
|||
@section scripts |
|||
{ |
|||
<abp-script-bundle name="@typeof(SelectAccountModel).FullName" /> |
|||
} |
|||
|
|||
<abp-card> |
|||
<abp-card-body> |
|||
@if (Model.AvailableAccounts.Any()) |
|||
{ |
|||
<form method="post" id="selectAccountForm"> |
|||
@Html.AntiForgeryToken() |
|||
|
|||
<input type="hidden" asp-for="Input.ClientId" value="@Model.ClientName" /> |
|||
<input type="hidden" asp-for="Input.RedirectUri" value="@Model.RedirectUri" /> |
|||
<input type="hidden" asp-for="Input.RememberSelection" value="true" /> |
|||
<input type="hidden" asp-for="Input.RememberMe" value="true" /> |
|||
|
|||
<div class="alert alert-info d-flex align-items-center mb-4"> |
|||
<i class="fas fa-user-check me-2"></i> |
|||
<span>@string.Format(@L["SignedInAs"].Value, @Model.UserName)</span> |
|||
</div> |
|||
|
|||
<div class="list-group list-group-flush rounded-3"> |
|||
@for (int i = 0; i < Model.AvailableAccounts.Count; i++) |
|||
{ |
|||
var account = Model.AvailableAccounts[i]; |
|||
var accountId = $"{account.UserId}@{account.TenantId}"; |
|||
var accountName = $"{account.UserName}"; |
|||
if (!string.IsNullOrWhiteSpace(account.TenantName)) |
|||
{ |
|||
accountName += "/" + account.TenantName; |
|||
} |
|||
// TODO: Date format |
|||
var lastLoginTime = account.LastLoginTime.HasValue ? account.LastLoginTime.Value.ToString("yyyy-MM:dd HH:mm:ss") : ""; |
|||
var isCurrent = account.IsCurrentAccount; |
|||
// TODO: 实现用户头像 |
|||
var avatar = !string.IsNullOrWhiteSpace(account.UserName) ? account.UserName[0].ToString().ToUpper() : "U"; |
|||
|
|||
<label class="list-group-item list-group-item-action border-0 cursor-pointer @(isCurrent ? "active" : "") account-item" |
|||
for="account-@i"> |
|||
<div class="d-flex align-items-center"> |
|||
<!-- 头像 --> |
|||
<div class="rounded-circle bg-primary text-white d-flex align-items-center justify-content-center me-3" |
|||
style="width: 40px; height: 40px; font-size: 16px; font-weight: 500;"> |
|||
@avatar |
|||
</div> |
|||
|
|||
<!-- 账户信息 --> |
|||
<div class="flex-grow-1 me-3"> |
|||
<div class="d-flex align-items-center"> |
|||
<span class="fw-semibold">@accountName</span> |
|||
@if (isCurrent) |
|||
{ |
|||
<span class="badge bg-success ms-2">@L["CurrentAccount"].Value</span> |
|||
} |
|||
</div> |
|||
<div class="text-muted small"> |
|||
@account.Email |
|||
</div> |
|||
@if (!string.IsNullOrWhiteSpace(lastLoginTime)) |
|||
{ |
|||
<div class="text-muted small"> |
|||
@L["LastLoginTime"]: @lastLoginTime |
|||
</div> |
|||
} |
|||
</div> |
|||
|
|||
<!-- 选择账户 --> |
|||
<input type="radio" |
|||
asp-for="Input.SelectedAccountId" |
|||
value="@accountId" |
|||
id="account-@i" |
|||
class="form-check-input" |
|||
style="width: 1.2em; height: 1.2em;" |
|||
checked="@isCurrent" /> |
|||
</div> |
|||
</label> |
|||
} |
|||
</div> |
|||
|
|||
<!-- 操作按钮 --> |
|||
<div class="d-grid gap-2 mt-2"> |
|||
<button type="submit" class="btn btn-primary" id="continueButton"> |
|||
@L["Continue"].Value |
|||
<i class="fas fa-arrow-right"></i> |
|||
</button> |
|||
</div> |
|||
</form> |
|||
} |
|||
else |
|||
{ |
|||
<div class="alert alert-warning mb-4"> |
|||
<i class="fas fa-info-circle me-2"></i> |
|||
@L["NoAccountsAvailable"].Value |
|||
</div> |
|||
} |
|||
|
|||
<!-- 其他选项 --> |
|||
<div class="d-grid gap-2 mt-2"> |
|||
<a href="@Url.Page("/Account/Login", new { ReturnUrl = Model.RedirectUri, prompt = "login" })" |
|||
class="btn btn-outline-primary"> |
|||
<i class="fas fa-exchange-alt me-2"></i> |
|||
@L["SwitchToAnotherAccount"].Value |
|||
</a> |
|||
<a href="@Url.Page("/Account/Register", new { ReturnUrl = Model.RedirectUri })" |
|||
class="btn btn-outline-primary"> |
|||
<i class="fas fa-user-plus me-2"></i> |
|||
@L["CreateNewAccount"].Value |
|||
</a> |
|||
</div> |
|||
</abp-card-body> |
|||
</abp-card> |
|||
@ -0,0 +1,468 @@ |
|||
using Microsoft.AspNetCore.Authorization; |
|||
using Microsoft.AspNetCore.Mvc; |
|||
using Microsoft.AspNetCore.WebUtilities; |
|||
using Microsoft.Extensions.Logging; |
|||
using Microsoft.Extensions.Options; |
|||
using Microsoft.Extensions.Primitives; |
|||
using OpenIddict.Abstractions; |
|||
using System; |
|||
using System.Collections.Generic; |
|||
using System.ComponentModel.DataAnnotations; |
|||
using System.Linq; |
|||
using System.Threading.Tasks; |
|||
using Volo.Abp.Account.Web.Pages.Account; |
|||
using Volo.Abp.Data; |
|||
using Volo.Abp.Identity; |
|||
using Volo.Abp.MultiTenancy; |
|||
using Volo.Abp.OpenIddict.Localization; |
|||
using Volo.Abp.Users; |
|||
using Volo.Abp.Validation; |
|||
using static LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account.SelectAccountModel; |
|||
|
|||
namespace LINGYUN.Abp.Account.Web.OpenIddict.Pages.Account; |
|||
|
|||
[Authorize] |
|||
public class SelectAccountModel : AccountPageModel |
|||
{ |
|||
private const string LastLoginTimeFieldName = "LastLoginTime"; |
|||
|
|||
[BindProperty(SupportsGet = true)] |
|||
public string RedirectUri { get; set; } |
|||
|
|||
public string ClientName { get; set; } |
|||
|
|||
public string UserName { get; set; } |
|||
|
|||
[BindProperty] |
|||
public SelectAccountInput Input { get; set; } |
|||
|
|||
public List<UserAccountInfo> AvailableAccounts { get; set; } = new(); |
|||
|
|||
protected IOpenIddictApplicationManager ApplicationManager => LazyServiceProvider.LazyGetRequiredService<IOpenIddictApplicationManager>(); |
|||
|
|||
protected ITenantStore TenantStore => LazyServiceProvider.LazyGetRequiredService<ITenantStore>(); |
|||
|
|||
public SelectAccountModel() |
|||
{ |
|||
LocalizationResourceType = typeof(AbpOpenIddictResource); |
|||
} |
|||
|
|||
public async virtual Task<IActionResult> OnGetAsync() |
|||
{ |
|||
// 检查用户是否已登录
|
|||
if (!User.Identity.IsAuthenticated) |
|||
{ |
|||
// 未登录,重定向到登录页面
|
|||
return RedirectToPage("/Account/Login", new |
|||
{ |
|||
ReturnUrl = Url.Page("/Account/SelectAccount", new { RedirectUri }), |
|||
Prompt = "select_account" |
|||
}); |
|||
} |
|||
|
|||
var requestInfo = await ParseOriginalRequestFromRedirectUriAsync(); |
|||
if (requestInfo == null) |
|||
{ |
|||
Alerts.Warning(L["InvalidSelectAccountRequest"]); |
|||
return Page(); |
|||
} |
|||
|
|||
var application = await ApplicationManager.FindByClientIdAsync(requestInfo.ClientId); |
|||
ClientName = await ApplicationManager.GetLocalizedDisplayNameAsync(application) ?? requestInfo.ClientId; |
|||
|
|||
var currentUser = await UserManager.GetUserAsync(User); |
|||
if (currentUser == null) |
|||
{ |
|||
await SignInManager.SignOutAsync(); |
|||
return RedirectToPage("/Account/Login", new |
|||
{ |
|||
ReturnUrl = requestInfo.RedirectUri |
|||
}); |
|||
} |
|||
|
|||
UserName = currentUser.UserName; |
|||
|
|||
AvailableAccounts = await DiscoverUserAccountsAsync(currentUser, requestInfo.ClientId); |
|||
|
|||
if (AvailableAccounts.Count == 0) |
|||
{ |
|||
Alerts.Warning(L["NoAvailableAccounts"]); |
|||
} |
|||
|
|||
// 仅关联一个账户时直接登录
|
|||
if (AvailableAccounts.Count == 1) |
|||
{ |
|||
return await HandleLoginAsync(currentUser, Input.RememberMe, Input.RememberSelection); |
|||
} |
|||
|
|||
return Page(); |
|||
} |
|||
|
|||
public async virtual Task<IActionResult> OnPostAsync() |
|||
{ |
|||
try |
|||
{ |
|||
ValidateModel(); |
|||
|
|||
await IdentityOptions.SetAsync(); |
|||
|
|||
var tenantUser = ParseSelectedAccountId(Input.SelectedAccountId); |
|||
if (tenantUser == null) |
|||
{ |
|||
Alerts.Warning(L["InvalidSelectAccount"]); |
|||
return Page(); |
|||
} |
|||
|
|||
var user = await ValidateSelectedAccountAsync(tenantUser.UserId, tenantUser.TenantId); |
|||
if (user == null) |
|||
{ |
|||
Alerts.Warning(L["InvalidSelectAccount"]); |
|||
return Page(); |
|||
} |
|||
|
|||
using (CurrentTenant.Change(tenantUser.TenantId)) |
|||
{ |
|||
return await HandleLoginAsync(user, Input.RememberMe, Input.RememberSelection); |
|||
} |
|||
} |
|||
catch (AbpIdentityResultException e) |
|||
{ |
|||
if (!string.IsNullOrWhiteSpace(e.Message)) |
|||
{ |
|||
Alerts.Warning(GetLocalizeExceptionMessage(e)); |
|||
return Page(); |
|||
} |
|||
|
|||
throw; |
|||
} |
|||
catch (AbpValidationException) |
|||
{ |
|||
return Page(); |
|||
} |
|||
} |
|||
|
|||
protected async virtual Task<IActionResult> HandleLoginAsync(IdentityUser user, bool rememberMe = false, bool rememberSelection = false) |
|||
{ |
|||
// 检查是否已经是当前用户
|
|||
var currentUser = await UserManager.GetUserAsync(User); |
|||
if (currentUser == null || currentUser.Id == user.Id) |
|||
{ |
|||
// 使用选择的账户登录
|
|||
// TODO: 实现 RememberMe
|
|||
await SignInManager.SignInAsync(user, rememberMe); |
|||
|
|||
// TODO: date format
|
|||
user.SetProperty(LastLoginTimeFieldName, Clock.Now.ToString("yyyy-MM-dd HH:mm:ss")); |
|||
|
|||
// TODO: 实现 RememberSelection
|
|||
if (rememberSelection) |
|||
{ |
|||
await SaveAccountSelectionAsync( |
|||
Input.ClientId, |
|||
user.Id, |
|||
user.TenantId); |
|||
} |
|||
} |
|||
|
|||
// 重定向回原始授权请求
|
|||
return await RedirectSafelyAsync(Input.RedirectUri); |
|||
} |
|||
|
|||
protected virtual Task<OriginalRequestInfo> ParseOriginalRequestFromRedirectUriAsync() |
|||
{ |
|||
if (string.IsNullOrWhiteSpace(RedirectUri)) |
|||
{ |
|||
return Task.FromResult<OriginalRequestInfo>(null); |
|||
} |
|||
|
|||
try |
|||
{ |
|||
var info = new OriginalRequestInfo(); |
|||
string queryString = null; |
|||
|
|||
if (RedirectUri.StartsWith("/")) |
|||
{ |
|||
int queryIndex = RedirectUri.IndexOf('?'); |
|||
if (queryIndex >= 0) |
|||
{ |
|||
queryString = RedirectUri.Substring(queryIndex + 1); |
|||
} |
|||
else |
|||
{ |
|||
// 没有查询参数,尝试直接解析整个字符串
|
|||
queryString = RedirectUri; |
|||
} |
|||
} |
|||
else if (RedirectUri.Contains("://")) |
|||
{ |
|||
try |
|||
{ |
|||
var uri = new Uri(RedirectUri); |
|||
queryString = uri.Query; |
|||
if (queryString.StartsWith("?")) |
|||
{ |
|||
queryString = queryString.Substring(1); |
|||
} |
|||
} |
|||
catch (UriFormatException) |
|||
{ |
|||
queryString = RedirectUri; |
|||
} |
|||
} |
|||
else |
|||
{ |
|||
queryString = RedirectUri; |
|||
} |
|||
|
|||
// 解析查询参数
|
|||
if (!string.IsNullOrWhiteSpace(queryString)) |
|||
{ |
|||
var query = QueryHelpers.ParseQuery(queryString); |
|||
|
|||
info.ClientId = GetQueryValue(query, "client_id"); |
|||
info.RedirectUri = GetQueryValue(query, "redirect_uri"); |
|||
info.ResponseType = GetQueryValue(query, "response_type"); |
|||
info.Scope = GetQueryValue(query, "scope"); |
|||
info.State = GetQueryValue(query, "state"); |
|||
info.Nonce = GetQueryValue(query, "nonce"); |
|||
info.CodeChallenge = GetQueryValue(query, "code_challenge"); |
|||
info.CodeChallengeMethod = GetQueryValue(query, "code_challenge_method"); |
|||
info.Prompt = GetQueryValue(query, "prompt"); |
|||
} |
|||
|
|||
return Task.FromResult(info); |
|||
} |
|||
catch (Exception ex) |
|||
{ |
|||
Logger.LogWarning(ex, "解析RedirectUri参数错误: {message}", ex.Message); |
|||
return Task.FromResult<OriginalRequestInfo>(null); |
|||
} |
|||
} |
|||
|
|||
protected async virtual Task<List<UserAccountInfo>> DiscoverUserAccountsAsync( |
|||
IdentityUser currentUser, |
|||
string clientId) |
|||
{ |
|||
var accounts = new List<UserAccountInfo> |
|||
{ |
|||
new UserAccountInfo |
|||
{ |
|||
IsCurrentAccount = true, |
|||
UserId = currentUser.Id.ToString(), |
|||
TenantId = CurrentTenant.Id, |
|||
TenantName = CurrentTenant.Name, |
|||
UserName = currentUser.UserName, |
|||
Email = currentUser.Email, |
|||
LastLoginTime = currentUser.GetProperty<DateTime?>(LastLoginTimeFieldName), |
|||
} |
|||
}; |
|||
|
|||
// 获取客户端允许的租户
|
|||
var allowedTenants = await GetAllowedTenantsForClientAsync(clientId); |
|||
|
|||
foreach (var tenant in allowedTenants) |
|||
{ |
|||
var tenantUserAccount = await GetTenantUserAccountInfoAsync(currentUser.UserName, tenant); |
|||
if (tenantUserAccount != null) |
|||
{ |
|||
accounts.Add(tenantUserAccount); |
|||
} |
|||
} |
|||
|
|||
// 按最后登录时间排序,当前账户排第一
|
|||
return accounts |
|||
.OrderByDescending(a => a.IsCurrentAccount) |
|||
.ThenByDescending(a => a.LastLoginTime) |
|||
.ToList(); |
|||
} |
|||
|
|||
protected async virtual Task<UserAccountInfo> GetTenantUserAccountInfoAsync(string userName, TenantInfo tenant) |
|||
{ |
|||
using (CurrentTenant.Change(tenant.Id, tenant.Name)) |
|||
{ |
|||
var user = await UserManager.FindByNameAsync(userName); |
|||
if (user != null) |
|||
{ |
|||
// 检查用户是否有效
|
|||
if (user.IsActive && !await UserManager.IsLockedOutAsync(user)) |
|||
{ |
|||
var lastLoginTime = user.GetProperty<DateTime?>(LastLoginTimeFieldName); |
|||
return new UserAccountInfo |
|||
{ |
|||
IsCurrentAccount = false, |
|||
UserId = user.Id.ToString(), |
|||
TenantId = tenant.Id, |
|||
TenantName = tenant.Name, |
|||
UserName = user.UserName, |
|||
Email = user.Email, |
|||
LastLoginTime = lastLoginTime, |
|||
}; |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
} |
|||
|
|||
protected async virtual Task<List<TenantInfo>> GetAllowedTenantsForClientAsync(string clientId) |
|||
{ |
|||
var tenants = new List<TenantInfo>(); |
|||
|
|||
if (string.IsNullOrWhiteSpace(clientId)) |
|||
{ |
|||
return tenants; |
|||
} |
|||
|
|||
var application = await ApplicationManager.FindByClientIdAsync(clientId); |
|||
if (application == null) |
|||
{ |
|||
return tenants; |
|||
} |
|||
|
|||
var properties = await ApplicationManager.GetPropertiesAsync(application); |
|||
if (properties.TryGetValue("AllowedTenants", out var allowedTenantsValue)) |
|||
{ |
|||
var tenantIds = allowedTenantsValue.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries); |
|||
|
|||
foreach (var tenantIdString in tenantIds) |
|||
{ |
|||
if (Guid.TryParse(tenantIdString.Trim(), out var tenantId)) |
|||
{ |
|||
var tenant = await TenantStore.FindAsync(tenantId); |
|||
if (tenant != null && tenant.IsActive) |
|||
{ |
|||
tenants.Add(new TenantInfo |
|||
{ |
|||
Id = tenant.Id, |
|||
Name = tenant.Name |
|||
}); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
|
|||
return tenants; |
|||
} |
|||
|
|||
protected virtual TenantUser ParseSelectedAccountId(string selectedAccountId) |
|||
{ |
|||
// 解析账户格式为: UserId@TenantId
|
|||
if (string.IsNullOrWhiteSpace(selectedAccountId)) |
|||
{ |
|||
return new TenantUser(); |
|||
} |
|||
|
|||
var parts = selectedAccountId.Split('@', StringSplitOptions.RemoveEmptyEntries); |
|||
if (parts.Length < 1) |
|||
{ |
|||
return new TenantUser(); |
|||
} |
|||
|
|||
if (Guid.TryParse(parts[0], out var userId)) |
|||
{ |
|||
if (parts.Length > 1 && |
|||
Guid.TryParse(parts[1], out var tenantId)) |
|||
{ |
|||
return new TenantUser(tenantId, userId); |
|||
} |
|||
return new TenantUser(null, userId); |
|||
} |
|||
|
|||
return new TenantUser(); |
|||
} |
|||
|
|||
protected virtual string GetQueryValue(Dictionary<string, StringValues> query, string key) |
|||
{ |
|||
return query.TryGetValue(key, out var value) ? value.ToString() : null; |
|||
} |
|||
|
|||
protected async virtual Task<IdentityUser> ValidateSelectedAccountAsync(Guid userId, Guid? tenantId) |
|||
{ |
|||
using (CurrentTenant.Change(tenantId)) |
|||
{ |
|||
var user = await UserManager.FindByIdAsync(userId.ToString()); |
|||
if (user == null) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
if (!user.IsActive) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
if (await UserManager.IsLockedOutAsync(user)) |
|||
{ |
|||
return null; |
|||
} |
|||
|
|||
return user; |
|||
} |
|||
} |
|||
|
|||
protected virtual Task SaveAccountSelectionAsync(string clientId, Guid userId, Guid? tenantId) |
|||
{ |
|||
// TODO: 保存用户当前选择账户, 下次选择账户时默认选择此账户
|
|||
return Task.CompletedTask; |
|||
} |
|||
|
|||
public class OriginalRequestInfo |
|||
{ |
|||
public string ClientId { get; set; } |
|||
public string RedirectUri { get; set; } |
|||
public string Scope { get; set; } |
|||
public string State { get; set; } |
|||
public string Nonce { get; set; } |
|||
public string ResponseType { get; set; } |
|||
public string CodeChallenge { get; set; } |
|||
public string CodeChallengeMethod { get; set; } |
|||
public string Prompt { get; set; } |
|||
} |
|||
|
|||
public class SelectAccountInput |
|||
{ |
|||
[Required] |
|||
public string SelectedAccountId { get; set; } |
|||
|
|||
[Required] |
|||
public string ClientId { get; set; } |
|||
|
|||
[Required] |
|||
public string RedirectUri { get; set; } |
|||
|
|||
public bool RememberSelection { get; set; } = true; |
|||
|
|||
public bool RememberMe { get; set; } = true; |
|||
} |
|||
|
|||
public class UserAccountInfo |
|||
{ |
|||
public string UserId { get; set; } |
|||
public Guid? TenantId { get; set; } |
|||
public string TenantName { get; set; } |
|||
public string UserName { get; set; } |
|||
public string Email { get; set; } |
|||
public DateTime? LastLoginTime { get; set; } |
|||
public bool IsCurrentAccount { get; set; } |
|||
} |
|||
|
|||
public class TenantInfo |
|||
{ |
|||
public Guid Id { get; set; } |
|||
public string Name { get; set; } |
|||
} |
|||
|
|||
public class TenantUser |
|||
{ |
|||
public Guid? TenantId { get; set; } |
|||
public Guid UserId { get; set; } |
|||
public TenantUser() |
|||
{ |
|||
|
|||
} |
|||
public TenantUser(Guid? tenantId, Guid userId) |
|||
{ |
|||
TenantId = tenantId; |
|||
UserId = userId; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,43 @@ |
|||
$(function () { |
|||
var l = abp.localization.getResource('AbpOpenIddict'); |
|||
|
|||
// 账户选择交互 - 使用 Bootstrap 的 list-group-item-action
|
|||
$('.account-item').on('click', function (e) { |
|||
// 如果点击的是链接或按钮,不处理
|
|||
if ($(e.target).is('a, button, .btn') || |
|||
$(e.target).closest('a, button, .btn').length) { |
|||
return; |
|||
} |
|||
|
|||
// 移除所有 active 状态
|
|||
$('.account-item').removeClass('active'); |
|||
|
|||
// 添加当前选中状态
|
|||
$(this).addClass('active'); |
|||
|
|||
// 选中对应的 radio button
|
|||
var radio = $(this).find('input[type="radio"]'); |
|||
radio.prop('checked', true); |
|||
}); |
|||
|
|||
// 表单提交验证
|
|||
$('#selectAccountForm').on('submit', function (e) { |
|||
var selectedAccount = $('input[name="Input.SelectedAccountId"]:checked').val(); |
|||
|
|||
if (!selectedAccount) { |
|||
e.preventDefault(); |
|||
abp.message.warn(l('SelectedAccountId')); |
|||
return false; |
|||
} |
|||
|
|||
return true; |
|||
}); |
|||
|
|||
// 初始选中第一个账户(如果不是当前账户)
|
|||
if (!$('.account-item.active').length) { |
|||
$('.account-item:first').trigger('click'); |
|||
} |
|||
|
|||
// 自动聚焦到选中的账户
|
|||
$('.account-item.active').find('input[type="radio"]').focus(); |
|||
}); |
|||
@ -0,0 +1,29 @@ |
|||
/* 鼠标手型 */ |
|||
.cursor-pointer { |
|||
cursor: pointer; |
|||
} |
|||
|
|||
/* 账户列表项悬停效果增强 */ |
|||
.account-item:hover { |
|||
background-color: var(--bs-list-group-action-hover-bg); |
|||
transform: translateY(-1px); |
|||
transition: all 0.2s ease; |
|||
} |
|||
|
|||
/* 当前账户特殊样式 */ |
|||
.account-item.active { |
|||
background-color: var(--bs-primary-bg-subtle); |
|||
border-left: 3px solid var(--bs-primary); |
|||
} |
|||
|
|||
/* 响应式调整 */ |
|||
@media (max-width: 576px) { |
|||
.min-vh-100 { |
|||
min-height: auto; |
|||
padding: 1rem 0; |
|||
} |
|||
|
|||
.card-body { |
|||
padding: 1rem !important; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue