使用 Blazor 开发内部后台(三):登录
James: 《使用Blazor開發內部后臺》系列是技術社區中一位朋友投稿的系列文章,介紹自己為公司的 WebForm 遺留系統使用 Blazor 重寫前端 UI 的經歷。
本文為第三篇,如果錯過了前兩篇,建議先閱讀一下:
使用 Blazor 開發內部后臺(一):認識Blazor使用?
使用?Blazor 開發內部后臺(二):了解 Blazor 組件
前言
前文為讀者介紹了Blazor及組件的相關基礎概念,現在讓我們來處理一些實際的問題。本文將介紹一個簡單的設計方案:如何基于Blazor開發內部后臺登錄頁面(及相關模塊)。為了方便初學者理解正文,本文會先介紹一些工程上必須掌握的基礎知識,有經驗的開發者可以選擇性跳過。
托管Blazor WA應用(Hosted Blazor Web Assembly)
Blazor WA應用可以單獨部署,稱之為獨立Blazor WA(Standalone),通常用于(不需要后端的)離線應用或者后端服務基于非ASP.NET?Core的情形。而將Blazor作為ASP.NET?Core應用的前端部分一起部署,則被稱為托管Blazor(Hosted)。很顯然,若要開發一個前后端分離的應用,采用托管Blazor,才能最大程度地發揮Blazor的開發和部署優勢。
項目基本結構
托管Blazor WA應用的項目解決方案,主要包含三大子項目:
XXX.Client客戶端項目:前端模塊,即Blazor應用。
XXX.Server服務端項目:后端模塊,通常是ASP.NET?Core Web API。在最后部署的時候,是由此項目進行發布的,因此該項目會引用Client項目。
XXX.Shared類庫項目:共享模塊,主要是存放前后端可以共用的數據或邏輯,其他2個項目都要引用它。
而針對Client項目,內部也有自己的默認結構,這里請讀者自行閱讀Blazor項目結構官方文檔,篇幅所限,后文將默認讀者已經熟悉這些基礎結構。
依賴注入
依賴注入是ASP.NET?Core里一個非常基礎的設計模式。Blazor里延續了和后端開發同樣的風格。例如前端向后端發送請求,需要使用HttpClient,在Program.cs文件里,可以看到:
public class Program{public static async Task Main(string[] args){var builder = WebAssemblyHostBuilder.CreateDefault(args);builder.RootComponents.Add<App>("#app");builder.Services.AddScoped(sp => new HttpClient{BaseAddress = new Uri(builder.HostEnvironment.BaseAddress),Timeout = TimeSpan.FromSeconds(3)});await builder.Build().RunAsync();}}又例如:我們按照Ant-Design-Blazor項目的《快速上手》說明,引入該開源組件Nuget包后,也需要在這里加上依賴注入的代碼行(其他需要的操作詳見項目文檔):
builder.Services.AddAntDesign();這對ASP.NET?Core后端開發者來說,完全沒有理解門檻。而在Page文件里,需要使用HttpClient時,只需要使用@inject關鍵詞聲明即可:
@inject HttpClient MyHttpClient<div>....... </div>@code{private async Task<string> GetAsync(){string rsp = await MyHttpClient.GetStringAsync(xxxx);return rsp;} }這里請讀者自行閱讀Blazor依賴注入的官方文檔。對Angular開發者來說,應該也會感到十分親切。
設計認證方式
談到登錄,自然最先要考慮登錄的認證方式,常見的有Cookie、Session或Token。對后端渲染的應用來說,使用Session應該更簡單;而對前后端分離的應用來說,后端Web API應當是無狀態的,因此一般只選擇Cookie或Token,由前端持有自己的身份票據,后端做驗證而不存儲。
而在Cookie和Token之間,我按照官方文檔的建議選擇了使用Json Web Token。這里有必要將官方的理由引用過來,方便讀者參考:
還有對 SPA 進行身份驗證的其他選項,例如使用 SameSite cookie。但是,Blazor WebAssembly 的工程設計決定,OAuth 和 OIDC 是在 Blazor WebAssembly 應用中進行身份驗證的最佳選擇。出于以下功能和安全原因,選擇了以?JSON Web 令牌 (JWT)?為基礎的基于令牌的身份驗證而不是基于 cookie 的身份驗證:使用基于令牌的協議可以減小攻擊面,因為并非所有請求中都會發送令牌。
服務器終結點不要求針對跨站點請求偽造 (CSRF)?進行保護,因為會顯式發送令牌。因此,可以將 Blazor WebAssembly 應用與 MVC 或 Razor Pages 應用一起托管。
令牌的權限比 cookie 窄。例如,令牌不能用于管理用戶帳戶或更改用戶密碼,除非顯式實現了此類功能。
令牌的生命周期更短(默認為一小時),這限制了攻擊時間窗口。還可隨時撤銷令牌。
自包含 JWT 向客戶端和服務器提供身份驗證進程保證。例如,客戶端可以檢測和驗證它收到的令牌是否合法,以及是否是在給定身份驗證過程中發出的。如果有第三方嘗試在身份驗證進程中偷換令牌,客戶端可以檢測被偷換的令牌并避免使用它。
OAuth 和 OIDC 的令牌不依賴于用戶代理行為正確以確保應用安全。
基于令牌的協議(例如 OAuth 和 OIDC)允許用同一組安全特征對托管和獨立應用進行驗證和授權。
官方最推薦的方式是使用OAuth和OIDC。但開發內部后臺,還要另搞一個OAuth服務器,對絕大多數開發者來說維護和部署成本過高了。所以我使用了傳統的Password模式+后端自生成JWT。對內部后臺應用來說,這么做已經足夠安全。
還需要考慮的問題是,前端如何存放JWT呢?我們仍有兩種選擇,Cookie和LocalStorage。如果拿到了JWT放到一個前端自生成的Cookie里……那為什么不一開始就用Cookie呢?顯得有些自我矛盾。我選擇了儲存到LocalStorage里。借助開源項目Blazor.LocalStorage,我們可以很輕松地達到目的,當然,跟Antd一樣要用到依賴注入:
builder.Services.AddBlazoredLocalStorage(config =>{config.JsonSerializerOptions.DictionaryKeyPolicy = JsonNamingPolicy.CamelCase;config.JsonSerializerOptions.IgnoreNullValues = true;config.JsonSerializerOptions.IgnoreReadOnlyProperties = true;config.JsonSerializerOptions.PropertyNameCaseInsensitive = true;config.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;config.JsonSerializerOptions.ReadCommentHandling = JsonCommentHandling.Skip;config.JsonSerializerOptions.WriteIndented = false;});設計后端接口
既然已經確認要使用JWT,那么后端自然要提供一個認證的接口:
public class AccountController : ApiControllerBase{private readonly IMemoryCache _cache;private readonly IOptionsMonitor<JwtOption> _jwtOpt;private readonly IPasswordCryptor _passwordCryptor;private readonly MyDbContext _efContext;public AccountController(ILogger<AccountController> logger,IMemoryCache cache,IOptionsMonitor<JwtOption> jwtOpt,IPasswordCryptor passwordCryptor,MyDbContext efContext) : base(logger){_cache = cache;_jwtOpt = jwtOpt;_passwordCryptor = passwordCryptor;_efContext = efContext;}[HttpPost]public async Task<IActionResult> Login([FromForm] LoginRqtDto rqtDto){var cryptedPwd = _passwordCryptor.Encrypt(rqtDto.Password, default);string adminIdCacheKey = CacheKeyHelper.GetAdminIdCacheKey(rqtDto.Account);if (!_cache.TryGetValue(adminIdCacheKey, out int adminId)){adminId = await _efContext.Admins.Where(a => a.Account == rqtDto.Account && a.Password == cryptedPwd).Select(a => a.AdminId).FirstOrDefaultAsync();if (adminId < 1){return Unauthorized();}_cache.Set(adminIdCacheKey, adminId, TimeSpan.FromDays(1));}else{bool checkPwd = await _efContext.Admins.AnyAsync(a => a.AdminId == adminId && a.Password == cryptedPwd);if (!checkPwd){return Unauthorized();}}var claims = new Claim[]{new(ClaimTypes.NameIdentifier, adminId.ToString()),new(ClaimTypes.Name, rqtDto.Account),new(ClaimTypes.Role, "admin")};var jwtSetting = _jwtOpt.CurrentValue;var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtSetting.Key));var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);var expiry = DateTime.Now.AddHours(jwtSetting.ExpiryInHours);var token = new JwtSecurityToken(jwtSetting.Issuer, jwtSetting.Audience, claims, expires: expiry, signingCredentials: creds);var tokenText = new JwtSecurityTokenHandler().WriteToken(token);return Ok(tokenText);}}還需要配置JWT相關的參數:
"JWT": {"Key": "xxx","Issuer": "xxx","Audience": "xxx","ExpiryInHours": 8}及依賴注入:
public static IServiceCollection AddAuth(this IServiceCollection services, IConfiguration configuration){services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(options =>{options.TokenValidationParameters = new TokenValidationParameters{ValidateIssuer = true,ValidateAudience = true,ValidateLifetime = true,ValidateIssuerSigningKey = true,ValidIssuer = configuration.GetValue<string>("JWT:Issuer"),ValidAudience = configuration.GetValue<string>("JWT:Audience"),IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(configuration.GetValue<string>("JWT:Key"))),RequireExpirationTime = true};});services.Configure<JwtOption>(configuration.GetSection("JWT"));return services;}以上代碼僅供讀者參考,可按實際需要增刪改。另有一句與本文主旨無關的提醒:雖然是內部后臺系統,但管理員登錄密碼還是要做加鹽Hash處理,明文保存密碼在任何地方都不可取!
設計前端服務
有的讀者可能更喜歡UI先行,那么可以先看下面一節“設計登錄頁面”。
有了跟后端一樣的依賴注入,我們可以將前端的認證也封裝成服務。在項目中增加Services文件夾,添加AuthService.cs文件:
using Blazored.LocalStorage; using Microsoft.AspNetCore.Components.Authorization;internal class AuthService : IAuthService{private readonly HttpClient _httpClient;private readonly AuthenticationStateProvider _authenticationStateProvider;private readonly ILocalStorageService _localStorage;public AuthService(HttpClient httpClient,AuthenticationStateProvider authenticationStateProvider,ILocalStorageService localStorage){_httpClient = httpClient;_authenticationStateProvider = authenticationStateProvider;_localStorage = localStorage;}public async Task<bool> Login(LoginRqtDto rqtDto){var content = new FormUrlEncodedContent(new KeyValuePair<string, string>[]{new(nameof(LoginRqtDto.Account), rqtDto.Account),new(nameof(LoginRqtDto.Password), rqtDto.Password),});using var rsp = await _httpClient.PostAsync("/account/login", content);if (!rsp.IsSuccessStatusCode){return false;}var authToken = await rsp.Content.ReadAsStringAsync();await _localStorage.SetItemAsync("authToken", authToken);((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsAuthenticated(rqtDto.Account);_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", authToken);return true;}public async Task Logout(){await _localStorage.RemoveItemAsync("authToken");((ApiAuthenticationStateProvider)_authenticationStateProvider).MarkUserAsLoggedOut();_httpClient.DefaultRequestHeaders.Authorization = null;}}首先要注意的是AuthenticationStateProvider,這是一個抽象類,由Microsoft.AspNetCore.Components.Authorization類庫提供,它用來提供當前用戶的認證狀態信息。既然是抽象類,我們需要自定義一個它的子類,基于JWT和LocalStorage實現它要求的規則(即GetAuthenticationStateAsync方法):
using System.Security.Claims; using Blazored.LocalStorage; using Microsoft.AspNetCore.Components.Authorization;public class ApiAuthenticationStateProvider : AuthenticationStateProvider{private readonly HttpClient _httpClient;private readonly ILocalStorageService _localStorage;public ApiAuthenticationStateProvider(HttpClient httpClient, ILocalStorageService localStorage){_httpClient = httpClient;_localStorage = localStorage;}public override async Task<AuthenticationState> GetAuthenticationStateAsync(){var savedToken = await _localStorage.GetItemAsync<string>("authToken");if (string.IsNullOrWhiteSpace(savedToken)){return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity()));}_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", savedToken);return new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(ParseClaimsFromJwt(savedToken), "jwt")));}public void MarkUserAsAuthenticated(string account){var authenticatedUser = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.Name, account) }, "apiauth"));var authState = Task.FromResult(new AuthenticationState(authenticatedUser));NotifyAuthenticationStateChanged(authState);}public void MarkUserAsLoggedOut(){var anonymousUser = new ClaimsPrincipal(new ClaimsIdentity());var authState = Task.FromResult(new AuthenticationState(anonymousUser));NotifyAuthenticationStateChanged(authState);}private static IEnumerable<Claim> ParseClaimsFromJwt(string jwt){var claims = new List<Claim>();var payload = jwt.Split('.')[1];var jsonBytes = ParseBase64WithoutPadding(payload);var keyValuePairs = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonBytes);if (keyValuePairs.TryGetValue(ClaimTypes.Role, out object roles) && roles is string rolesText){if (rolesText.StartsWith('[')){var parsedRoles = JsonSerializer.Deserialize<string[]>(rolesText);foreach (var parsedRole in parsedRoles){claims.Add(new Claim(ClaimTypes.Role, parsedRole));}}else{claims.Add(new Claim(ClaimTypes.Role, rolesText));}keyValuePairs.Remove(ClaimTypes.Role);}claims.AddRange(keyValuePairs.Select(kvp => new Claim(kvp.Key, kvp.Value.ToString())));return claims;}private static byte[] ParseBase64WithoutPadding(string base64){switch (base64.Length % 4){case 2: base64 += "=="; break;case 3: base64 += "="; break;}return Convert.FromBase64String(base64);}}邏輯并不復雜。以上代碼需要讀者對JWT和System.Security.Claims類庫比較熟悉,建議初學者動手實踐和調試。
ILocalStorageService自然是由上文提到的Blazor.LocalStorage類庫依賴注入。
之前系列文章都提到了Blazor在.NET全棧開發下,具有極大的開發效率優勢。這里就有體現——既然后端已經提供了接口,注意到LoginRqtDto類:
using System.ComponentModel.DataAnnotations;public class LoginRqtDto{[Display(Name = "賬號")][Required][StringLength(20, MinimumLength = 3)]public string Account { get; set; }[Display(Name = "密碼")][Required][StringLength(20, MinimumLength = 5]public string Password { get; set; }}我們自然可以將該類放到Shared項目中,使得前端Blazor項目在調用Login接口時可以不必再另寫請求參數的Model。另外,不單單是類本身的屬性,特性也可以被前后端共同利用,這一點放到下文再講。
寫完了該服務,可別忘了依賴注入!我的習慣是讓Program.cs里的代碼盡可能精簡,因此,我會創建一個Extensions文件夾,添加ServiceCollectionExtension.cs文件:
using Microsoft.AspNetCore.Components.Authorization; using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Microsoft.Extensions.DependencyInjection;internal static class ServiceCollectionExtension{public static IServiceCollection AddAuth(this IServiceCollection services){services.AddAuthorizationCore().AddScoped<AuthenticationStateProvider, ApiAuthenticationStateProvider>().AddScoped<IAuthService, AuthService>();return services;}}現在只需要在Program.cs里加一行代碼:
builder.Services.AddAuth();設計登錄頁面
登錄頁面的獨特之處,在于布局。例如內容頁面是有側邊導航欄的,但登錄頁面顯然就沒什么必要了。因此,我建議單獨寫一個LoginLayout組件,和默認布局MainLayout分開,只用于Login頁面:
@inherits LayoutComponentBase<Layout Style="padding:0;margin:0"><Header Style="height:10%"><div style="margin:10px;"><AntDesign.Row Justify="space-around" Align="middle"><AntDesign.Col Span="8"><img src="/imgs/logo.png" style="align-self:center" /></AntDesign.Col><AntDesign.Col Span="8" Offset="8" Style="text-align:center"><span style="color:white; font-size:24px">歡迎使用 @ProductionName 后臺管理系統</span></AntDesign.Col></AntDesign.Row></div></Header><Content Style="background-color:white; min-height:500px"><AntDesign.Row><AntDesign.Col Span="20" Offset="2"><div style="margin:100px 0">@Body</div></AntDesign.Col></AntDesign.Row></Content><MyFooter /> </Layout>@code {private const string ProductionName = "Demo"; }借助于Antd的Layout和Grid組件,可以很輕松地搭建整個Login頁面的布局,這里我采用了最簡單的上中下三層布局。注意到@Body,Body是一種約定命名,表示布局內的頁面主體。
對Login頁面來說,@Body其實就是賬戶輸入、密碼輸入和登錄按鈕。讓我們在Pages文件夾里添加一個Login.razor:
@page "/login" @layout LoginLayout @inject NavigationManager NavigationManager @inject MessageService MsgService @inject IAuthService AuthService<AntDesign.Form Model="@_loginData" Style="height:100%"OnFinish="OnFinish"LabelColSpan="4"WrapperColSpan="4"><FormItem WrapperColOffset="10" WrapperColSpan="4"><AntDesign.Input Placeholder="請輸入賬號" AllowClear="true" @bind-Value="@context.Account"><Prefix><Icon Type="user"></Icon></Prefix></AntDesign.Input></FormItem><FormItem WrapperColOffset="10" WrapperColSpan="4"><InputPassword Placeholder="請輸入密碼" @bind-Value="@context.Password"><Prefix><Icon Type="lock"></Icon></Prefix></InputPassword></FormItem><FormItem WrapperColOffset="11" WrapperColSpan="2"><Button Type="@ButtonType.Primary" HtmlType="submit" Block>登錄</Button></FormItem> </AntDesign.Form>@code {private LoginRqtDto _loginData = new();private async Task OnFinish(EditContext editContext){var result = await AuthService.Login(_loginData);if (!result){await MsgService.Error("帳號或密碼錯誤!");return;}await MsgService.Success("登錄成功!");NavigationManager.NavigateTo("/home");} }我們使用@layout指令來指定當前頁面組件使用哪一種布局;使用Antd提供的Form組件,可以很方便地完成控件布局并添加提交功能;再一次使用LoginRqtDto類,將其屬性與控件的值雙向綁定,實現最大化代碼復用;使用依賴注入,在頁面內方便地調用內置的NavigationManager和Antd提供的MessageService,分別用于頁面跳轉和消息提示。
頁面效果如下:
登錄頁面依賴于Antd組件的出色實現,諸如密碼的開閉顯示等細節,都不必我們手動實現。還有一些細節并未在上面的代碼里體現。例如,后端使用System.ComponentModel.DataAnnotations類庫,可以很方便地對接口參數進行校驗(如上文提到的LoginRqtDto類)。那么同樣是使用C#,Blazor是否也可以這樣做呢?
當然可以!Antd組件同樣利用了接口參數的校驗特性!相較于一般前后端開發,都需要通過API文檔、團隊紀律和組織溝通,來保證前后端各種數據和邏輯的一致性。而使用Blazor開發,在代碼層面就可以天然地讓前后端的行為一致!只要讓定義接口的人將自己的數據放到Shared項目里即可。
前端校驗提示(關于上圖,有過Antd-Blazor開發經驗的讀者可能會好奇:這里校驗提示為什么是中文而不是默認的英文?我將在下文“本地化校驗提示”做簡要說明。)
使用AuthorizeView組件動態顯示內容
登錄頁面及服務設計好之后,還沒有結束。對SPA應用來說,每個頁面有自己單獨的路由,用戶可以手動輸入路由繞過登錄頁面來訪問其他頁面。我們理所應當地希望如果用戶未登錄或認證失敗,那么其他頁面對用戶將不提供任何有價值的數據。
對后端來說,數據相關的接口都必須加上[Authorize]特性,以校驗訪問者的身份。
對前端來說,應當以友好的方式提示用戶登錄,而不是依舊發送頁面請求,依賴后端接口返回401或403再手動處理。
MainLayout和AuthorizeView組件可以幫助我們統一處理這種情況。
使用AuthorizeView組件之前,我們需要在App.razor文件里,使用CascadingAuthenticationState組件包裹Router組件:
@using Microsoft.AspNetCore.Components.Authorization<CascadingAuthenticationState><Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true"><Found Context="routeData"><AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" /></Found><NotFound> <MyNotFound /></NotFound></Router> </CascadingAuthenticationState><AntContainer />然后在MainLayout的Content部分使用AuthorizeView組件:
<Content Style="background-color:white; min-height:500px"><AuthorizeView><Authorized>@Body</Authorized><NotAuthorized><div style="margin: 100px 0; width:100%; text-align: center; color: red;"><span style="font-size:20px">檢測到登錄超時,請重新<a href="/login" style="text-decoration:underline">登錄</a>!</span></div></NotAuthorized></AuthorizeView><BackTop></BackTop></Content>單從標簽命名上看就很容易理解:認證通過則顯示@Body的內容,否則顯示一行字提示用戶訪問登錄頁。讓我們看下不登錄情況下直接訪問Home首頁的效果:
NotAuthorized時的Content這樣,對于默認使用MainLayout布局的其他所有頁面,若用戶未認證,則只會顯示上圖的效果。同理,我們可以實現布局的Header部分動態顯示:未認證情況下,不應顯示上方“首頁/關于”導航欄和右上方的賬號信息,這里本文不再贅述。
本地化校驗提示
至此本文核心內容都已經結束了。但在編寫登錄頁面的過程中,有一個細節值得一提。
在設計登錄頁面一節中,我提到了前端校驗提示。目前Antd組件在校驗提示上,還是使用System.ComponentModel.DataAnnotations類庫的默認提示:提示是全英文的。
在上文提到的LoginRqtDto中,我們可以使用Display特性,來修改校驗失敗提示時屬性的展示名稱。但并不能修改整個提示的內容,因此讀者只會看到中英文混合的一段提示文本。
注意到校驗特性的父類ValidationAttribute,有ErrorMessageResourceName和ErrorMessageResourceType兩個屬性。也就是說該父類在設計上,是支持本地化的,我們可以創建Resource資源,來替換類庫默認的錯誤提示。
在XXX.Shared項目中,創建Resources文件夾,添加一個DA_zh_CN.resx文件(命名隨意):
中文提示資源IDE VS會自動生成一個的DA_zh_CN.designer.cs文件,為你創建DA_zh_CN類。
將上文提到的LoginRqtDto改為:
public class LoginRqtDto{[Display(Name = "賬號")][Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))][StringLength(20, MinimumLength = 3, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]public string Account { get; set; }[Display(Name = "密碼")][Required(ErrorMessageResourceName = "RequiredError", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))][StringLength(20, MinimumLength = 5, ErrorMessageResourceName = "StringLengthError_IncludingMin", ErrorMessageResourceType = typeof(Resources.DA_zh_CN))]public string Password { get; set; }}好了,收工。這里resx文件里“名稱”列,我也不是隨意取的,而是照搬官方源碼里的名稱。有興趣的讀者可以參閱System.ComponentModel.DataAnnotations類庫的相關源碼。
我也希望未來能有更簡單的方式實現控件本地化校驗提示。
結束語
下一篇文章會簡單許多,我將介紹如何使用Antd的Card組件和優雅的Razor語法,做一個可靈活配置的、用于導航的首頁。再會!
總結
以上是生活随笔為你收集整理的使用 Blazor 开发内部后台(三):登录的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 这批.Net程序员水平不行啊!居然ASP
- 下一篇: VisualStudio配置中文提示