Network Security Internet Technology Development Database Servers Mobile Phone Android Software Apple Software Computer Software News IT Information

In addition to Weibo, there is also WeChat

Please pay attention

WeChat public account

Shulou

How does ASP.NET Core MVC modify the default path of the view and how it is implemented?

2025-01-15 Update From: SLTechnology News&Howtos shulou NAV: SLTechnology News&Howtos > Development >

Share

Shulou(Shulou.com)06/03 Report--

In this issue, the editor will bring you about the default path of ASP.NET Core MVC view modification and its implementation principle. The article is rich in content and analyzes and describes for you from a professional point of view. I hope you can get something after reading this article.

Introduction: in the course of daily work, you may encounter such a requirement, that is, when you visit the same page, the content and style displayed on the PC side and the mobile end are not the same (similar to two different topics), but their back-end code is similar. At this time, we want to be able to use the same set of back-end code, and then it is up to the system to automatically determine whether the PC end access or the mobile end access. If it is accessed by the mobile terminal, the view of the mobile terminal is matched first, and the view of the PC side is matched only if there is no match.

Let's take a look at how to implement this function. The directory structure of Demo is as follows:

The Web project of this Demo is the MVC project of the ASP.NET Core Web application (the target framework is .NET Core 3.1).

First, you need to expand the default path of the view, as follows:

Using System;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;using Microsoft.AspNetCore.Mvc.Razor Namespace NETCoreViewLocationExpander.ViewLocationExtend {/ View default path extension / public class TemplateViewLocationExpander: IViewLocationExpander {/ extended View default path (PS: this method is not executed every request) / public IEnumerable ExpandViewLocations (ViewLocationExpanderContext context IEnumerable viewLocations) {var template = context.Values ["template"]? TemplateEnum.Default.ToString () If (template = = TemplateEnum.WeChatArea.ToString ()) {string [] weChatAreaViewLocationFormats = {"/ Areas/ {2} / WeChatViews/ {1} / {0} .cshtml", "/ Areas/ {2} / WeChatViews/Shared/ {0} .cshtml", "/ WeChatViews/Shared/ {0} .cshtml"} / / weChatAreaViewLocationFormats value comes first-- priority to find weChatAreaViewLocationFormats (that is, priority to find mobile directory) return weChatAreaViewLocationFormats.Union (viewLocations) } else if (template = = TemplateEnum.WeChat.ToString ()) {string [] weChatViewLocationFormats = {"/ WeChatViews/ {1} / {0} .cshtml", "/ WeChatViews/Shared/ {0} .cshtml"} / / weChatViewLocationFormats value comes first-first to find weChatViewLocationFormats (that is, priority to find mobile directory) return weChatViewLocationFormats.Union (viewLocations);} return viewLocations } / add key-value pairs to ViewLocationExpanderContext.Values (PS: this method is executed every time a request is made) / public void PopulateValues (ViewLocationExpanderContext context) {var userAgent = context.ActionContext.HttpContext.Request.Headers ["User-Agent"] .ToString (); var isMobile = IsMobile (userAgent) Var template = TemplateEnum.Default.ToString (); if (isMobile) {var areaName = / / region name context.ActionContext.RouteData.Values.ContainsKey ("area")? Context.ActionContext.RouteData.Values ["area"] .ToString (): "; var controllerName = / / controller name context.ActionContext.RouteData.Values.ContainsKey (" controller ")? Context.ActionContext.RouteData.Values ["controller"] .ToString (): "; if (! string.IsNullOrEmpty (areaName) & &! string.IsNullOrEmpty (controllerName)) / / accesses the area {template = TemplateEnum.WeChatArea.ToString () } else {template = TemplateEnum.WeChat.ToString ();}} context.Values ["template"] = template / / context.Values will participate in the generation of ViewLookupCache cache Key (cacheKey)} / protected bool IsMobile (string userAgent) {userAgent = userAgent.ToLower () If (userAgent = = "" | userAgent.IndexOf ("mobile") >-1 | | userAgent.IndexOf ("mobi") >-1 | | userAgent.IndexOf ("nokia") >-1 | | userAgent.IndexOf ("samsung") >-1 | | userAgent.IndexOf ("sonyericsson") >-1 | | userAgent.IndexOf (" Mot ") >-1 | | userAgent.IndexOf (" blackberry ") >-1 | | userAgent.IndexOf (" lg ") >-1 | | userAgent.IndexOf (" htc ") >-1 | | userAgent.IndexOf (" j2me ") >-1 | | userAgent.IndexOf (" ucweb ") >-1 | userAgent.IndexOf (" opera mini ") >-| 1 | | userAgent.IndexOf ("android") >-1 | | userAgent.IndexOf ("transcoder") >-1) {return true } return false;}} / template enumeration / public enum TemplateEnum {Default = 1, WeChat = 2, WeChatArea = 3}}

Then modify the Startup.cs class, as follows:

Using Microsoft.AspNetCore.Builder;using Microsoft.AspNetCore.Hosting;using Microsoft.AspNetCore.Mvc.Razor;using Microsoft.Extensions.Configuration;using Microsoft.Extensions.DependencyInjection;using Microsoft.Extensions.Hosting;using System;using System.Collections.Generic;using System.Linq;using System.Threading.Tasks;using NETCoreViewLocationExpander.ViewLocationExtend;namespace NETCoreViewLocationExpander {public class Startup {public Startup (IConfiguration configuration) {Configuration = configuration;} public IConfiguration Configuration {get } / / This method gets called by the runtime. Use this method to add services to the container. Public void ConfigureServices (IServiceCollection services) {services.AddControllersWithViews (); services.Configure (options = > {options.ViewLocationExpanders.Add (new TemplateViewLocationExpander ()); / / View default path extension});} / / This method gets called by the runtime. Use this method to configure the HTTP request pipeline. Public void Configure (IApplicationBuilder app, IWebHostEnvironment env) {if (env.IsDevelopment ()) {app.UseDeveloperExceptionPage ();} else {app.UseExceptionHandler ("/ Home/Error");} app.UseStaticFiles (); app.UseRouting () App.UseAuthorization (); app.UseEndpoints (endpoints = > {endpoints.MapControllerRoute (name: "areas", pattern: "{area:exists} / {controller=Home} / {action=Index} / {id?}") Endpoints.MapControllerRoute (name: "default", pattern: "{controller=Home} / {action=Index} / {id?}"););}

In addition, two sets of views are prepared in Demo:

The end view of the PC is as follows:

The view of the mobile terminal is as follows:

Finally, we use the PC side and the mobile side to access the relevant pages, as follows:

1. Visit the / App/Home/Index page

Using PC access, the running result is as follows:

Using mobile access, the running result is as follows:

At this time, there is no corresponding mobile view, so the view content on the PC side is returned.

2. Visit / App/Home/WeChat page

Using PC access, the running result is as follows:

Using mobile access, the running result is as follows:

At this time, there is a corresponding mobile view, so when using mobile access, the view content of mobile is returned, while when using PC access, the view content of PC is returned.

Let's analyze its implementation principle with the ASP.NET Core source code:

ASP.NET Core source code download address: https://github.com/dotnet/aspnetcore

Click Source code to download. When the download is complete, click Release:

You can download the extensions source code together. After the download is completed, it is shown below:

After decompression, let's focus on the Razor View engine (RazorViewEngine.cs):

< expandersCount; i++) { expanders[i].PopulateValues(expanderContext); } } var cacheKey = new ViewLocationCacheKey( expanderContext.ViewName, expanderContext.ControllerName, expanderContext.AreaName, expanderContext.PageName, expanderContext.IsMainPage, expanderValues); if (!ViewLookupCache.TryGetValue(cacheKey, out ViewLocationCacheResult cacheResult)) { _logger.ViewLookupCacheMiss(cacheKey.ViewName, cacheKey.ControllerName); cacheResult = OnCacheMiss(expanderContext, cacheKey); } else { _logger.ViewLookupCacheHit(cacheKey.ViewName, cacheKey.ControllerName); } return cacheResult; } /// public string GetAbsolutePath(string executingFilePath, string pagePath) { if (string.IsNullOrEmpty(pagePath)) { // Path is not valid; no change required. return pagePath; } if (IsApplicationRelativePath(pagePath)) { // An absolute path already; no change required. return pagePath; } if (!IsRelativePath(pagePath)) { // A page name; no change required. return pagePath; } if (string.IsNullOrEmpty(executingFilePath)) { // Given a relative path i.e. not yet application-relative (starting with "~/" or "/"), interpret // path relative to currently-executing view, if any. // Not yet executing a view. Start in app root. var absolutePath = "/" + pagePath; return ViewEnginePath.ResolvePath(absolutePath); } return ViewEnginePath.CombinePath(executingFilePath, pagePath); } // internal for tests internal IEnumerable GetViewLocationFormats(ViewLocationExpanderContext context) { if (!string.IsNullOrEmpty(context.AreaName) && !string.IsNullOrEmpty(context.ControllerName)) { return _options.AreaViewLocationFormats; } else if (!string.IsNullOrEmpty(context.ControllerName)) { return _options.ViewLocationFormats; } else if (!string.IsNullOrEmpty(context.AreaName) && !string.IsNullOrEmpty(context.PageName)) { return _options.AreaPageViewLocationFormats; } else if (!string.IsNullOrEmpty(context.PageName)) { return _options.PageViewLocationFormats; } else { // If we don't match one of these conditions, we'll just treat it like regular controller/action // and use those search paths. This is what we did in 1.0.0 without giving much thought to it. return _options.ViewLocationFormats; } } private ViewLocationCacheResult OnCacheMiss( ViewLocationExpanderContext expanderContext, ViewLocationCacheKey cacheKey) { var viewLocations = GetViewLocationFormats(expanderContext); var expanders = _options.ViewLocationExpanders; // Read interface .Count once rather than per iteration var expandersCount = expanders.Count; for (var i = 0; i < expandersCount; i++) { viewLocations = expanders[i].ExpandViewLocations(expanderContext, viewLocations); } ViewLocationCacheResult cacheResult = null; var searchedLocations = new List(); var expirationTokens = new HashSet(); foreach (var location in viewLocations) { var path = string.Format( CultureInfo.InvariantCulture, location, expanderContext.ViewName, expanderContext.ControllerName, expanderContext.AreaName); path = ViewEnginePath.ResolvePath(path); cacheResult = CreateCacheResult(expirationTokens, path, expanderContext.IsMainPage); if (cacheResult != null) { break; } searchedLocations.Add(path); } // No views were found at the specified location. Create a not found result. if (cacheResult == null) { cacheResult = new ViewLocationCacheResult(searchedLocations); } var cacheEntryOptions = new MemoryCacheEntryOptions(); cacheEntryOptions.SetSlidingExpiration(_cacheExpirationDuration); foreach (var expirationToken in expirationTokens) { cacheEntryOptions.AddExpirationToken(expirationToken); } return ViewLookupCache.Set(cacheKey, cacheResult, cacheEntryOptions); } // Internal for unit testing internal ViewLocationCacheResult CreateCacheResult( HashSet expirationTokens, string relativePath, bool isMainPage) { var factoryResult = _pageFactory.CreateFactory(relativePath); var viewDescriptor = factoryResult.ViewDescriptor; if (viewDescriptor?.ExpirationTokens != null) { var viewExpirationTokens = viewDescriptor.ExpirationTokens; // Read interface .Count once rather than per iteration var viewExpirationTokensCount = viewExpirationTokens.Count; for (var i = 0; i < viewExpirationTokensCount; i++) { expirationTokens.Add(viewExpirationTokens[i]); } } if (factoryResult.Success) { // Only need to lookup _ViewStarts for the main page. var viewStartPages = isMainPage ? GetViewStartPages(viewDescriptor.RelativePath, expirationTokens) : Array.Empty(); return new ViewLocationCacheResult( new ViewLocationCacheItem(factoryResult.RazorPageFactory, relativePath), viewStartPages); } return null; } private IReadOnlyList GetViewStartPages( string path, HashSet expirationTokens) { var viewStartPages = new List(); foreach (var filePath in RazorFileHierarchy.GetViewStartPaths(path)) { var result = _pageFactory.CreateFactory(filePath); var viewDescriptor = result.ViewDescriptor; if (viewDescriptor?.ExpirationTokens != null) { for (var i = 0; i < viewDescriptor.ExpirationTokens.Count; i++) { expirationTokens.Add(viewDescriptor.ExpirationTokens[i]); } } if (result.Success) { // Populate the viewStartPages list so that _ViewStarts appear in the order the need to be // executed (closest last, furthest first). This is the reverse order in which // ViewHierarchyUtility.GetViewStartLocations returns _ViewStarts. viewStartPages.Insert(0, new ViewLocationCacheItem(result.RazorPageFactory, filePath)); } } return viewStartPages; } private ViewEngineResult CreateViewEngineResult(ViewLocationCacheResult result, string viewName) { if (!result.Success) { return ViewEngineResult.NotFound(viewName, result.SearchedLocations); } var page = result.ViewEntry.PageFactory(); var viewStarts = new IRazorPage[result.ViewStartEntries.Count]; for (var i = 0; i < viewStarts.Length; i++) { var viewStartItem = result.ViewStartEntries[i]; viewStarts[i] = viewStartItem.PageFactory(); } var view = new RazorView(this, _pageActivator, viewStarts, page, _htmlEncoder, _diagnosticListener); return ViewEngineResult.Found(viewName, view); } private static bool IsApplicationRelativePath(string name) { Debug.Assert(!string.IsNullOrEmpty(name)); return name[0] == '~' || name[0] == '/'; } private static bool IsRelativePath(string name) { Debug.Assert(!string.IsNullOrEmpty(name)); // Though ./ViewName looks like a relative path, framework searches for that view using view locations. return name.EndsWith(ViewExtension, StringComparison.OrdinalIgnoreCase); } }} 我们从用于寻找视图的 FindView 方法开始阅读: /// public ViewEngineResult FindView(ActionContext context, string viewName, bool isMainPage){ if (context == null) { throw new ArgumentNullException(nameof(context)); } if (string.IsNullOrEmpty(viewName)) { throw new ArgumentException(Resources.ArgumentCannotBeNullOrEmpty, nameof(viewName)); } if (IsApplicationRelativePath(viewName) || IsRelativePath(viewName)) { // A path; not a name this method can handle. return ViewEngineResult.NotFound(viewName, Enumerable.Empty()); } var cacheResult = LocatePageFromViewLocations(context, viewName, isMainPage); return CreateViewEngineResult(cacheResult, viewName);} 接着定位找到LocatePageFromViewLocations 方法: private ViewLocationCacheResult LocatePageFromViewLocations( ActionContext actionContext, string pageName, bool isMainPage){ var controllerName = GetNormalizedRouteValue(actionContext, ControllerKey); var areaName = GetNormalizedRouteValue(actionContext, AreaKey); string razorPageName = null; if (actionContext.ActionDescriptor.RouteValues.ContainsKey(PageKey)) { // Only calculate the Razor Page name if "page" is registered in RouteValues. razorPageName = GetNormalizedRouteValue(actionContext, PageKey); } var expanderContext = new ViewLocationExpanderContext( actionContext, pageName, controllerName, areaName, razorPageName, isMainPage); Dictionary expanderValues = null; var expanders = _options.ViewLocationExpanders; // Read interface .Count once rather than per iteration var expandersCount = expanders.Count; if (expandersCount >

0) {expanderValues = new Dictionary (StringComparer.Ordinal); expanderContext.Values = expanderValues; / / Perf: Avoid allocations for (var I = 0; I < expandersCount; iTunes +) {expandersCount; [I] .PopulateValues (expanderContext) }} var cacheKey = new ViewLocationCacheKey (expanderContext.ViewName, expanderContext.ControllerName, expanderContext.AreaName, expanderContext.PageName, expanderContext.IsMainPage, expanderValues); if (! ViewLookupCache.TryGetValue (cacheKey, out ViewLocationCacheResult cacheResult)) {_ logger.ViewLookupCacheMiss (cacheKey.ViewName, cacheKey.ControllerName); cacheResult = OnCacheMiss (expanderContext, cacheKey) } else {_ logger.ViewLookupCacheHit (cacheKey.ViewName, cacheKey.ControllerName);} return cacheResult;}

As you can see here, the ViewLocationExpander.PopulateValues method is called every time you look up a view, and the final expanderValues participates in the generation of the ViewLookupCache cache key (cacheKey).

You can also see that if the data can be found in the ViewLookupCache cache, it will return directly and will not call the ViewLocationExpander.ExpandViewLocations method again.

This explains why we set the value of context.Values ["template"] in the PopulateValues method in Demo instead of directly in the ExpandViewLocations method.

Let's go on to find the ViewLocationCacheKey class used to generate the cacheKey, as follows:

/ / Copyright (c) .NET Foundation. All rights reserved.// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.using System;using System.Collections.Generic;using Microsoft.Extensions.Internal;namespace Microsoft.AspNetCore.Mvc.Razor {/ Key for entries in. / internal readonly struct ViewLocationCacheKey: IEquatable {/ Initializes a new instance of. / The view name or path. / Determines if the page being found is the main page for an action. Public ViewLocationCacheKey (string viewName, bool isMainPage): this (viewName, controllerName: null, areaName: null, pageName: null, isMainPage: isMainPage Values: null) {} / Initializes a new instance of. / The view name. / The controller name. / The area name. / The page name. / Determines if the page being found is the main page for an action. / Values from instances. Public ViewLocationCacheKey (string viewName, string controllerName, string areaName, string pageName, bool isMainPage, IReadOnlyDictionary values) {ViewName = viewName; ControllerName = controllerName; AreaName = areaName; PageName = pageName; IsMainPage = isMainPage; ViewLocationExpanderValues = values } / Gets the view name. / public string ViewName {get;} / Gets the controller name. / public string ControllerName {get;} / Gets the area name. / public string AreaName {get;} / Gets the page name. / public string PageName {get;} / Determines if the page being found is the main page for an action. / public bool IsMainPage {get;} / Gets the values populated by instances. / public IReadOnlyDictionary ViewLocationExpanderValues {get } / / public bool Equals (ViewLocationCacheKey y) {if (IsMainPage! = y.IsMainPage | |! string.Equals (ViewName, y.ViewName, StringComparison.Ordinal) | |! string.Equals (ControllerName, y.ControllerName, StringComparison.Ordinal) | |! string.Equals (AreaName, y.AreaName, StringComparison.Ordinal) | |! string.Equals (PageName, y.PageName) StringComparison.Ordinal) {return false } if (ReferenceEquals (ViewLocationExpanderValues, y.ViewLocationExpanderValues)) {return true;} if (ViewLocationExpanderValues = = null | | y.ViewLocationExpanderValues = = null | | (ViewLocationExpanderValues.Count! = y.ViewLocationExpanderValues.Count)) {return false } foreach (var item in ViewLocationExpanderValues) {if (! y.ViewLocationExpanderValues.TryGetValue (item.Key, out var yValue) | |! string.Equals (item.Value, yValue, StringComparison.Ordinal) {return false;}} return true } / public override bool Equals (object obj) {if (obj is ViewLocationCacheKey) {return Equals ((ViewLocationCacheKey) obj);} return false;} / public override int GetHashCode () {var hashCodeCombiner = HashCodeCombiner.Start () HashCodeCombiner.Add (IsMainPage? 1: 0); hashCodeCombiner.Add (ViewName, StringComparer.Ordinal); hashCodeCombiner.Add (ControllerName, StringComparer.Ordinal); hashCodeCombiner.Add (AreaName, StringComparer.Ordinal); hashCodeCombiner.Add (PageName, StringComparer.Ordinal) If (ViewLocationExpanderValues! = null) {foreach (var item in ViewLocationExpanderValues) {hashCodeCombiner.Add (item.Key, StringComparer.Ordinal); hashCodeCombiner.Add (item.Value, StringComparer.Ordinal);}} return hashCodeCombiner;}

Let's focus on the Equals method, as shown below:

/ / public bool Equals (ViewLocationCacheKey y) {if (IsMainPage! = y.IsMainPage | |! string.Equals (ViewName, y.ViewName, StringComparison.Ordinal) | |! string.Equals (ControllerName, y.ControllerName, StringComparison.Ordinal) | |! string.Equals (AreaName, y.AreaName, StringComparison.Ordinal) | |! string.Equals (PageName, y.PageName, StringComparison.Ordinal) {return false } if (ReferenceEquals (ViewLocationExpanderValues, y.ViewLocationExpanderValues)) {return true;} if (ViewLocationExpanderValues = = null | | y.ViewLocationExpanderValues = = null | | (ViewLocationExpanderValues.Count! = y.ViewLocationExpanderValues.Count)) {return false } foreach (var item in ViewLocationExpanderValues) {if (! y.ViewLocationExpanderValues.TryGetValue (item.Key, out var yValue) | |! string.Equals (item.Value, yValue, StringComparison.Ordinal) {return false;}} return true;}

As you can see from this, if the number of key / value pairs in the expanderValues dictionary is different or any one of them is different, then the cacheKey is different.

As we move on, we know from the above that if no data is found in the ViewLookupCache cache, it executes the OnCacheMiss method.

We found the OnCacheMiss method, as follows:

Private ViewLocationCacheResult OnCacheMiss (ViewLocationExpanderContext expanderContext, ViewLocationCacheKey cacheKey) {var viewLocations = GetViewLocationFormats (expanderContext); var expanders = _ options.ViewLocationExpanders; / / Read interface .Count once rather than per iteration var expandersCount = expanders.Count; for (var I = 0; I < expandersCount; iframes +) {viewLocations = developers [I] .ExpandViewLocations (expanderContext, viewLocations);} ViewLocationCacheResult cacheResult = null; var searchedLocations = new List (); var expirationTokens = new HashSet () Foreach (var location in viewLocations) {var path = string.Format (CultureInfo.InvariantCulture, location, expanderContext.ViewName, expanderContext.ControllerName, expanderContext.AreaName); path = ViewEnginePath.ResolvePath (path); cacheResult = CreateCacheResult (expirationTokens, path, expanderContext.IsMainPage); if (cacheResult! = null) {break } searchedLocations.Add (path);} / / No views were found at the specified location. Create a not found result. If (cacheResult = = null) {cacheResult = new ViewLocationCacheResult (searchedLocations);} var cacheEntryOptions = new MemoryCacheEntryOptions (); cacheEntryOptions.SetSlidingExpiration (_ cacheExpirationDuration); foreach (var expirationToken in expirationTokens) {cacheEntryOptions.AddExpirationToken (expirationToken);} return ViewLookupCache.Set (cacheKey, cacheResult, cacheEntryOptions);}

After careful observation, you will find that:

1. First of all, it gets the initial collection of viewLocations view positions through the GetViewLocationFormats method.

2. Then it calls all the ViewLocationExpander.ExpandViewLocations methods sequentially, and after a series of aggregation operations, it gets the final collection of viewLocations view locations.

3. Then traverse the viewLocations view location collection, look for the corresponding view in the specified path in turn, end the cycle as long as you find the first view that meets the criteria, stop looking down, and finally set the cache to return the result.

4. The placeholder in the view location string (for example: "/ Areas/ {2} / WeChatViews/ {1} / {0} .cshtml") means: "{0}" represents the view name, "{1}" represents the controller name, and "{2}" represents the region name.

Let's continue to find the GetViewLocationFormats method, as shown below:

/ / internal for testsinternal IEnumerable GetViewLocationFormats (ViewLocationExpanderContext context) {if (! string.IsNullOrEmpty (context.AreaName) & &! string.IsNullOrEmpty (context.ControllerName)) {return _ options.AreaViewLocationFormats;} else if (! string.IsNullOrEmpty (context.ControllerName)) {return _ options.ViewLocationFormats } else if (! string.IsNullOrEmpty (context.AreaName) & &! string.IsNullOrEmpty (context.PageName)) {return _ options.AreaPageViewLocationFormats;} else if (! string.IsNullOrEmpty (context.PageName)) {return _ options.PageViewLocationFormats;} else {/ / If we don't match one of these conditions, we'll just treat it like regular controller/action / / and use those search paths. This is what we did in 1.0.0 without giving much thought to it. Return _ options.ViewLocationFormats;}}

It can be seen here that it determines whether the client accesses a zone or a non-zone by determining whether neither the zone name nor the controller name is empty.

At the end of the article, we look at the initial values of AreaViewLocationFormats and ViewLocationFormats through debugging:

The above is the default path and implementation principle of the ASP.NET Core MVC modification view shared by the editor. If you happen to have similar doubts, you might as well refer to the above analysis to understand. If you want to know more about it, you are welcome to follow the industry information channel.

Welcome to subscribe "Shulou Technology Information " to get latest news, interesting things and hot topics in the IT industry, and controls the hottest and latest Internet news, technology news and IT industry trends.

Views: 0

*The comments in the above article only represent the author's personal views and do not represent the views and positions of this website. If you have more insights, please feel free to contribute and share.

Share To

Development

Wechat

© 2024 shulou.com SLNews company. All rights reserved.

12
Report