Browse Source

房贷计算

懒得勤快 2 năm trước cách đây
mục cha
commit
e2532f58ed

+ 183 - 177
src/Masuit.MyBlogs.Core/Controllers/ToolsController.cs

@@ -23,185 +23,191 @@ namespace Masuit.MyBlogs.Core.Controllers;
 [Route("tools")]
 public sealed class ToolsController : BaseController
 {
-	private readonly HttpClient _httpClient;
-
-	public ToolsController(IHttpClientFactory httpClientFactory)
-	{
-		_httpClient = httpClientFactory.CreateClient();
-	}
-
-	/// <summary>
-	/// 获取ip地址详细信息
-	/// </summary>
-	/// <param name="ip"></param>
-	/// <returns></returns>
-	[Route("ip"), Route("ip/{ip?}", Order = 1), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "ip" })]
-	public async Task<ActionResult> GetIpInfo([IsIPAddress] string ip)
-	{
-		ViewBag.IP = ip;
-		if (!IPAddress.TryParse(ip, out var ipAddress))
-		{
-			ipAddress = ClientIP;
-			ViewBag.IP = ClientIP;
-		}
-
-		if (ipAddress.IsPrivateIP())
-		{
-			return Ok("内网IP");
-		}
-
-		var loc = ipAddress.GetIPLocation();
-		var asn = ipAddress.GetIPAsn();
-		var nslookup = new LookupClient();
-		using var cts = new CancellationTokenSource(2000);
-		var domain = await nslookup.QueryReverseAsync(ipAddress, cts.Token).ContinueWith(t => t.IsCompletedSuccessfully ? t.Result.Answers.Select(r => r.ToString()).Join("; ") : "无");
-		var address = new IpInfo
-		{
-			Location = loc.Coodinate,
-			Address = loc.Address,
-			Address2 = loc.Address2,
-			Network = new NetworkInfo
-			{
-				Asn = asn.AutonomousSystemNumber,
-				Router = asn.Network + "",
-				Organization = loc.ISP
-			},
-			TimeZone = loc.Coodinate.TimeZone + $"  UTC{TZConvert.GetTimeZoneInfo(loc.Coodinate.TimeZone ?? "Asia/Shanghai").BaseUtcOffset.Hours:+#;-#;0}",
-			IsProxy = loc.Network.Contains(new[] { "cloud", "Compute", "Serv", "Tech", "Solution", "Host", "云", "Datacenter", "Data Center", "Business", "ASN" }) || domain.Length > 1 || await IsProxy(ipAddress, cts.Token),
-			Domain = domain
-		};
-		if (Request.Method.Equals(HttpMethods.Get) || (Request.Headers[HeaderNames.Accept] + "").StartsWith(ContentType.Json))
-		{
-			return View(address);
-		}
-
-		return Json(address);
-	}
-
-	/// <summary>
-	/// 是否是代理ip
-	/// </summary>
-	/// <param name="ip"></param>
-	/// <param name="cancellationToken"></param>
-	/// <returns></returns>
-	private async Task<bool> IsProxy(IPAddress ip, CancellationToken cancellationToken = default)
-	{
-		_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36 Edg/92.0.902.62");
-		return await _httpClient.GetStringAsync("https://ipinfo.io/" + ip, cancellationToken).ContinueWith(t =>
-		{
-			if (t.IsCompletedSuccessfully)
-			{
-				var ctx = BrowsingContext.New(Configuration.Default);
-				var doc = ctx.OpenAsync(res => res.Content(t.Result)).Result;
-				var isAnycast = doc.DocumentElement.QuerySelectorAll(".title").Where(e => e.TextContent.Contains("Anycast")).Select(e => e.Parent).Any(n => n.TextContent.Contains("True"));
-				var isproxy = doc.DocumentElement.QuerySelectorAll("#block-privacy img").Any(e => e.OuterHtml.Contains("right"));
-				return isAnycast || isproxy;
-			}
-			return false;
-		});
-	}
-
-	/// <summary>
-	/// 根据经纬度获取详细地理信息
-	/// </summary>
-	/// <param name="lat"></param>
-	/// <param name="lng"></param>
-	/// <returns></returns>
-	[HttpGet("pos"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "lat", "lng" })]
-	public async Task<ActionResult> Position(string lat, string lng)
-	{
-		if (string.IsNullOrEmpty(lat) || string.IsNullOrEmpty(lng))
-		{
-			var ip = ClientIP;
+    private readonly HttpClient _httpClient;
+
+    public ToolsController(IHttpClientFactory httpClientFactory)
+    {
+        _httpClient = httpClientFactory.CreateClient();
+    }
+
+    /// <summary>
+    /// 获取ip地址详细信息
+    /// </summary>
+    /// <param name="ip"></param>
+    /// <returns></returns>
+    [Route("ip"), Route("ip/{ip?}", Order = 1), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "ip" })]
+    public async Task<ActionResult> GetIpInfo([IsIPAddress] string ip)
+    {
+        ViewBag.IP = ip;
+        if (!IPAddress.TryParse(ip, out var ipAddress))
+        {
+            ipAddress = ClientIP;
+            ViewBag.IP = ClientIP;
+        }
+
+        if (ipAddress.IsPrivateIP())
+        {
+            return Ok("内网IP");
+        }
+
+        var loc = ipAddress.GetIPLocation();
+        var asn = ipAddress.GetIPAsn();
+        var nslookup = new LookupClient();
+        using var cts = new CancellationTokenSource(2000);
+        var domain = await nslookup.QueryReverseAsync(ipAddress, cts.Token).ContinueWith(t => t.IsCompletedSuccessfully ? t.Result.Answers.Select(r => r.ToString()).Join("; ") : "无");
+        var address = new IpInfo
+        {
+            Location = loc.Coodinate,
+            Address = loc.Address,
+            Address2 = loc.Address2,
+            Network = new NetworkInfo
+            {
+                Asn = asn.AutonomousSystemNumber,
+                Router = asn.Network + "",
+                Organization = loc.ISP
+            },
+            TimeZone = loc.Coodinate.TimeZone + $"  UTC{TZConvert.GetTimeZoneInfo(loc.Coodinate.TimeZone ?? "Asia/Shanghai").BaseUtcOffset.Hours:+#;-#;0}",
+            IsProxy = loc.Network.Contains(new[] { "cloud", "Compute", "Serv", "Tech", "Solution", "Host", "云", "Datacenter", "Data Center", "Business", "ASN" }) || domain.Length > 1 || await IsProxy(ipAddress, cts.Token),
+            Domain = domain
+        };
+        if (Request.Method.Equals(HttpMethods.Get) || (Request.Headers[HeaderNames.Accept] + "").StartsWith(ContentType.Json))
+        {
+            return View(address);
+        }
+
+        return Json(address);
+    }
+
+    /// <summary>
+    /// 是否是代理ip
+    /// </summary>
+    /// <param name="ip"></param>
+    /// <param name="cancellationToken"></param>
+    /// <returns></returns>
+    private async Task<bool> IsProxy(IPAddress ip, CancellationToken cancellationToken = default)
+    {
+        _httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36 Edg/92.0.902.62");
+        return await _httpClient.GetStringAsync("https://ipinfo.io/" + ip, cancellationToken).ContinueWith(t =>
+        {
+            if (t.IsCompletedSuccessfully)
+            {
+                var ctx = BrowsingContext.New(Configuration.Default);
+                var doc = ctx.OpenAsync(res => res.Content(t.Result)).Result;
+                var isAnycast = doc.DocumentElement.QuerySelectorAll(".title").Where(e => e.TextContent.Contains("Anycast")).Select(e => e.Parent).Any(n => n.TextContent.Contains("True"));
+                var isproxy = doc.DocumentElement.QuerySelectorAll("#block-privacy img").Any(e => e.OuterHtml.Contains("right"));
+                return isAnycast || isproxy;
+            }
+            return false;
+        });
+    }
+
+    /// <summary>
+    /// 根据经纬度获取详细地理信息
+    /// </summary>
+    /// <param name="lat"></param>
+    /// <param name="lng"></param>
+    /// <returns></returns>
+    [HttpGet("pos"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "lat", "lng" })]
+    public async Task<ActionResult> Position(string lat, string lng)
+    {
+        if (string.IsNullOrEmpty(lat) || string.IsNullOrEmpty(lng))
+        {
+            var ip = ClientIP;
 #if DEBUG
-			var r = new Random();
-			ip = IPAddress.Parse($"{r.Next(210)}.{r.Next(255)}.{r.Next(255)}.{r.Next(255)}");
+            var r = new Random();
+            ip = IPAddress.Parse($"{r.Next(210)}.{r.Next(255)}.{r.Next(255)}.{r.Next(255)}");
 #endif
-			var location = Policy<CityResponse>.Handle<AddressNotFoundException>().Fallback(() => new CityResponse()).Execute(() => CommonHelper.MaxmindReader.City(ip));
-			var address = new PhysicsAddress()
-			{
-				Status = 0,
-				AddressResult = new AddressResult()
-				{
-					FormattedAddress = ip.GetIPLocation().Address,
-					Location = new Location()
-					{
-						Lng = (decimal)location.Location.Longitude.GetValueOrDefault(),
-						Lat = (decimal)location.Location.Latitude.GetValueOrDefault()
-					}
-				}
-			};
-			return View(address);
-		}
-
-		using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-		var s = await _httpClient.GetStringAsync($"http://api.map.baidu.com/geocoder/v2/?location={lat},{lng}&output=json&pois=1&ak={AppConfig.BaiduAK}", cts.Token).ContinueWith(t =>
-		{
-			if (t.IsCompletedSuccessfully)
-			{
-				return JsonConvert.DeserializeObject<PhysicsAddress>(t.Result);
-			}
-
-			return new PhysicsAddress();
-		});
-
-		return View(s);
-	}
-
-	/// <summary>
-	/// 详细地理信息转经纬度
-	/// </summary>
-	/// <param name="addr"></param>
-	/// <returns></returns>
-	[Route("addr"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "addr" })]
-	public async Task<ActionResult> Address(string addr)
-	{
-		if (string.IsNullOrEmpty(addr))
-		{
-			var ip = ClientIP;
+            var location = Policy<CityResponse>.Handle<AddressNotFoundException>().Fallback(() => new CityResponse()).Execute(() => CommonHelper.MaxmindReader.City(ip));
+            var address = new PhysicsAddress()
+            {
+                Status = 0,
+                AddressResult = new AddressResult()
+                {
+                    FormattedAddress = ip.GetIPLocation().Address,
+                    Location = new Location()
+                    {
+                        Lng = (decimal)location.Location.Longitude.GetValueOrDefault(),
+                        Lat = (decimal)location.Location.Latitude.GetValueOrDefault()
+                    }
+                }
+            };
+            return View(address);
+        }
+
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        var s = await _httpClient.GetStringAsync($"http://api.map.baidu.com/geocoder/v2/?location={lat},{lng}&output=json&pois=1&ak={AppConfig.BaiduAK}", cts.Token).ContinueWith(t =>
+        {
+            if (t.IsCompletedSuccessfully)
+            {
+                return JsonConvert.DeserializeObject<PhysicsAddress>(t.Result);
+            }
+
+            return new PhysicsAddress();
+        });
+
+        return View(s);
+    }
+
+    /// <summary>
+    /// 详细地理信息转经纬度
+    /// </summary>
+    /// <param name="addr"></param>
+    /// <returns></returns>
+    [Route("addr"), ResponseCache(Duration = 600, VaryByQueryKeys = new[] { "addr" })]
+    public async Task<ActionResult> Address(string addr)
+    {
+        if (string.IsNullOrEmpty(addr))
+        {
+            var ip = ClientIP;
 #if DEBUG
-			Random r = new Random();
-			ip = IPAddress.Parse($"{r.Next(210)}.{r.Next(255)}.{r.Next(255)}.{r.Next(255)}");
+            Random r = new Random();
+            ip = IPAddress.Parse($"{r.Next(210)}.{r.Next(255)}.{r.Next(255)}.{r.Next(255)}");
 #endif
-			var location = Policy<CityResponse>.Handle<AddressNotFoundException>().Fallback(() => new CityResponse()).Execute(() => CommonHelper.MaxmindReader.City(ip));
-			var address = new PhysicsAddress()
-			{
-				Status = 0,
-				AddressResult = new AddressResult
-				{
-					FormattedAddress = ip.GetIPLocation().Address,
-					Location = new Location
-					{
-						Lng = (decimal)location.Location.Longitude.GetValueOrDefault(),
-						Lat = (decimal)location.Location.Latitude.GetValueOrDefault()
-					}
-				}
-			};
-			ViewBag.Address = address.AddressResult.FormattedAddress;
-			if (Request.Method.Equals(HttpMethods.Get) || (Request.Headers[HeaderNames.Accept] + "").StartsWith(ContentType.Json))
-			{
-				return View(address.AddressResult.Location);
-			}
-
-			return Json(address.AddressResult.Location);
-		}
-
-		ViewBag.Address = addr;
-		using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
-		var physicsAddress = await _httpClient.GetStringAsync($"http://api.map.baidu.com/geocoder/v2/?output=json&address={addr}&ak={AppConfig.BaiduAK}", cts.Token).ContinueWith(t =>
-		{
-			if (t.IsCompletedSuccessfully)
-			{
-				return JsonConvert.DeserializeObject<PhysicsAddress>(t.Result);
-			}
-
-			return new PhysicsAddress();
-		});
-		if (Request.Method.Equals(HttpMethods.Get) || (Request.Headers[HeaderNames.Accept] + "").StartsWith(ContentType.Json))
-		{
-			return View(physicsAddress?.AddressResult?.Location);
-		}
-
-		return Json(physicsAddress?.AddressResult?.Location);
-	}
+            var location = Policy<CityResponse>.Handle<AddressNotFoundException>().Fallback(() => new CityResponse()).Execute(() => CommonHelper.MaxmindReader.City(ip));
+            var address = new PhysicsAddress()
+            {
+                Status = 0,
+                AddressResult = new AddressResult
+                {
+                    FormattedAddress = ip.GetIPLocation().Address,
+                    Location = new Location
+                    {
+                        Lng = (decimal)location.Location.Longitude.GetValueOrDefault(),
+                        Lat = (decimal)location.Location.Latitude.GetValueOrDefault()
+                    }
+                }
+            };
+            ViewBag.Address = address.AddressResult.FormattedAddress;
+            if (Request.Method.Equals(HttpMethods.Get) || (Request.Headers[HeaderNames.Accept] + "").StartsWith(ContentType.Json))
+            {
+                return View(address.AddressResult.Location);
+            }
+
+            return Json(address.AddressResult.Location);
+        }
+
+        ViewBag.Address = addr;
+        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
+        var physicsAddress = await _httpClient.GetStringAsync($"http://api.map.baidu.com/geocoder/v2/?output=json&address={addr}&ak={AppConfig.BaiduAK}", cts.Token).ContinueWith(t =>
+        {
+            if (t.IsCompletedSuccessfully)
+            {
+                return JsonConvert.DeserializeObject<PhysicsAddress>(t.Result);
+            }
+
+            return new PhysicsAddress();
+        });
+        if (Request.Method.Equals(HttpMethods.Get) || (Request.Headers[HeaderNames.Accept] + "").StartsWith(ContentType.Json))
+        {
+            return View(physicsAddress?.AddressResult?.Location);
+        }
+
+        return Json(physicsAddress?.AddressResult?.Location);
+    }
+
+    [HttpGet("loan")]
+    public ActionResult Loan()
+    {
+        return View();
+    }
 }

+ 16 - 6
src/Masuit.MyBlogs.Core/Masuit.MyBlogs.Core.csproj

@@ -49,19 +49,19 @@
         <PackageReference Include="CHTCHSConv" Version="1.0.0" />
         <PackageReference Include="CLRStats" Version="1.0.0" />
         <PackageReference Include="Dispose.Scope.AspNetCore" Version="0.0.3" />
-        <PackageReference Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.4" />
+        <PackageReference Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.5" />
         <PackageReference Include="EntityFrameworkCore.Exceptions.PostgreSQL" Version="6.0.3" />
-        <PackageReference Include="FreeRedis" Version="1.1.10" />
+        <PackageReference Include="FreeRedis" Version="1.1.12" />
         <PackageReference Include="Hangfire" Version="1.8.6" />
         <PackageReference Include="Hangfire.MemoryStorage" Version="1.8.0" />
         <PackageReference Include="htmldiff.net" Version="1.4.1" />
         <PackageReference Include="Karambolo.AspNetCore.Bundling.NUglify" Version="3.7.0" />
         <PackageReference Include="Markdig" Version="0.33.0" />
         <PackageReference Include="MaxMind.GeoIP2" Version="5.1.0" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" />
-        <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.12" />
-        <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.12" />
-        <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.12" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.13" />
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="7.0.13" />
+        <PackageReference Include="Microsoft.EntityFrameworkCore.Proxies" Version="7.0.13" />
+        <PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.0.13" />
         <PackageReference Include="Microsoft.Graph" Version="4.54.0" />
         <PackageReference Include="Microsoft.Graph.Auth" Version="1.0.0-preview.7" />
         <PackageReference Include="Microsoft.NETCore.Platforms" Version="7.0.4" />
@@ -86,4 +86,14 @@
           <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
         </None>
     </ItemGroup>
+    <ItemGroup>
+      <UpToDateCheckInput Remove="Views\Tools\Loan.cshtml" />
+    </ItemGroup>
+    <ItemGroup>
+      <_ContentIncludedByDefault Remove="Views\Tools\Loan.cshtml" />
+      <_ContentIncludedByDefault Remove="Views\Tools\Loan.razor" />
+    </ItemGroup>
+    <ItemGroup>
+      <UpToDateCheckInput Remove="Views\Tools\Loan.razor" />
+    </ItemGroup>
 </Project>

+ 204 - 204
src/Masuit.MyBlogs.Core/Views/Post/PostVisitRecordInsight.cshtml

@@ -1,224 +1,224 @@
 @model Masuit.MyBlogs.Core.Models.Entity.Post
 
 @{
-	Layout = null;
+    Layout = null;
 }
 
 <!DOCTYPE html>
 <html>
 <head>
-	<meta charset="utf-8">
-	<title>文章《@Model.Title》洞察分析</title>
-	<meta content="webkit" name="renderer">
-	<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
-	<meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport">
-	<link href="/Assets/layui/css/layui.min.css" media="all" rel="stylesheet">
-	<style>
-		.mp-results.mp-bottomleft {
-			top: unset !important;
-			bottom: 0;
-		}
-	</style>
+    <meta charset="utf-8">
+    <title>文章《@Model.Title》洞察分析</title>
+    <meta content="webkit" name="renderer">
+    <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
+    <meta content="width=device-width, initial-scale=1, maximum-scale=1" name="viewport">
+    <link href="/Assets/layui/css/layui.min.css" media="all" rel="stylesheet">
+    <style>
+        .mp-results.mp-bottomleft {
+            top: unset !important;
+            bottom: 0;
+        }
+    </style>
 </head>
 <body style="overflow-x: hidden">
-	<h3 align="center">文章《@Model.Title》洞察分析</h3>
-	<div class="searchTable">
-		<div class="layui-inline">
-			<input class="layui-input" name="kw" id="kw">
-		</div>
-		<button class="layui-btn" data-type="reload">搜索</button>
-		<a class="layui-btn" asp-controller="Post" asp-action="ExportPostVisitRecords" asp-route-id="@Model.Id">导出</a>
-	</div>
-	<table class="layui-hide" id="table" lay-filter="tableEvent"></table>
-	<form class="layui-form">
-		<label class="layui-form-label">对比最近</label>
-		<div class="layui-input-inline">
-			<select id="period" name="period" lay-filter="period">
-				<option value="0">不对比</option>
-				<option value="7">一周</option>
-				<option value="15">15天</option>
-			    <option value="30" selected="selected">一个月</option>
-				<option value="60">两个月</option>
-				<option value="90">三个月</option>
-				<option value="180">半年</option>
-			</select>
-		</div>
+    <h3 align="center">文章《@Model.Title》洞察分析</h3>
+    <div class="searchTable">
+        <div class="layui-inline">
+            <input class="layui-input" name="kw" id="kw">
+        </div>
+        <button class="layui-btn" data-type="reload">搜索</button>
+        <a class="layui-btn" asp-controller="Post" asp-action="ExportPostVisitRecords" asp-route-id="@Model.Id">导出</a>
+    </div>
+    <table class="layui-hide" id="table" lay-filter="tableEvent"></table>
+    <form class="layui-form">
+        <label class="layui-form-label">对比最近</label>
+        <div class="layui-input-inline">
+            <select id="period" name="period" lay-filter="period">
+                <option value="0">不对比</option>
+                <option value="7">一周</option>
+                <option value="15">15天</option>
+                <option value="30" selected="selected">一个月</option>
+                <option value="60">两个月</option>
+                <option value="90">三个月</option>
+                <option value="180">半年</option>
+            </select>
+        </div>
     </form>
-	<div id="chart" style="height: 500px"></div>
-	<mini-profiler max-traces="5" />
+    <div id="chart" style="height: 500px"></div>
+    <mini-profiler max-traces="5" />
 </body>
 </html>
 <script src="/Assets/layui/layui.js"></script>
 <script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js" type="text/javascript"></script>
 <script>
-	layui.use('table', function() {
-		var table = layui.table;
-		table.render({
-			elem: '#table',
-			url: '/@Model.Id/records',
-			cellMinWidth: 80, //全局定义常规单元格的最小宽度,layui 2.2.1 新增
-			cols: [
-				[
-					{ field: 'IP', title: 'IP', align: 'center', event: 'tool-ip', width:320 },
-					{ field: 'Location', title: '位置和网络', align: 'center'},
-					{ field: 'RequestUrl', title: '请求URL', align: 'center' },
-					{ field: 'Referer', title: '页面来源', align: 'center', event: 'visit' },
-					{ field: 'Time', title: '访问时间', align: 'center',width:180 }
-				]
-			],
-			page: true,
-			limit:20,
-			request: {
-				limitName: 'size' //每页数据量的参数名,默认:limit
-			},
-			parseData: function(res) { //res 即为原始返回的数据
-				return {
-					"code": res.TotalCount > 0 ? 0 : 1, //解析接口状态
-					"msg": "暂无数据", //解析提示文本
-					"count": res.TotalCount, //解析数据长度
-					"data": res.Data //解析数据列表
-				};
-			}
-		});
-		table.on('tool(tableEvent)', function(obj){
-			var data = obj.data;
-			if(obj.event === 'tool-ip'){
-				window.open("/tools/ip/"+data.IP);
-			}
+    layui.use('table', function() {
+        var table = layui.table;
+        table.render({
+            elem: '#table',
+            url: '/@Model.Id/records',
+            cellMinWidth: 80, //全局定义常规单元格的最小宽度,layui 2.2.1 新增
+            cols: [
+                [
+                    { field: 'IP', title: 'IP', align: 'center', event: 'tool-ip', width:320 },
+                    { field: 'Location', title: '位置和网络', align: 'center'},
+                    { field: 'RequestUrl', title: '请求URL', align: 'center' },
+                    { field: 'Referer', title: '页面来源', align: 'center', event: 'visit' },
+                    { field: 'Time', title: '访问时间', align: 'center',width:180 }
+                ]
+            ],
+            page: true,
+            limit:20,
+            request: {
+                limitName: 'size' //每页数据量的参数名,默认:limit
+            },
+            parseData: function(res) { //res 即为原始返回的数据
+                return {
+                    "code": res.TotalCount > 0 ? 0 : 1, //解析接口状态
+                    "msg": "暂无数据", //解析提示文本
+                    "count": res.TotalCount, //解析数据长度
+                    "data": res.Data //解析数据列表
+                };
+            }
+        });
+        table.on('tool(tableEvent)', function(obj){
+            var data = obj.data;
+            if(obj.event === 'tool-ip'){
+                window.open("/tools/ip/"+data.IP);
+            }
 
-			if(obj.event === 'visit'){
-				window.open(data.Referer);
-			}
-		});
+            if(obj.event === 'visit'){
+                window.open(data.Referer);
+            }
+        });
 
-		var $ = layui.$;
-		$('.searchTable .layui-btn').on('click', function () {
-			table.reload('table', {
-				page: {
-					curr: 1
-				},
-				where: {
-					kw: $('#kw').val()
-				}
-			});
-		});
-	});
-	layui.use("form", function() {
-		var form = layui.form;
-		form.on("select(period)", function (data) {
-			var chartDom = document.getElementById('chart');
-			echarts.init(chartDom).dispose();
-			showCharts();
-		});
+        var $ = layui.$;
+        $('.searchTable .layui-btn').on('click', function () {
+            table.reload('table', {
+                page: {
+                    curr: 1
+                },
+                where: {
+                    kw: $('#kw').val()
+                }
+            });
+        });
     });
-	showCharts();
-	function showCharts() {
-		var period = document.getElementById("period").value;
-		window.fetch(`/@Model.Id/records-chart?compare=${period > 0}&period=${period}`, {
-			credentials: 'include',
-			method: 'GET',
-			mode: 'cors'
-		}).then(function (response) {
-			return response.json();
-		}).then(function (res) {
-			var xSeries = [];
-			var yCountSeries = [];
-			var yUvSeries = [];
-			for (let series of res) {
-				var x = [];
-				var yCount = [];
-				var yUV = [];
-				for (let item of series) {
-					x.push(new Date(Date.parse(item.Date)).toLocaleDateString());
-					yCount.push(item.Count);
-					yUV.push(item.UV);
-				}
-				xSeries.push(x);
-				yCountSeries.push(yCount);
-				yUvSeries.push(yUV);
-			}
-			var chartDom = document.getElementById('chart');
-			var myChart = echarts.init(chartDom);
-			const colors = ['#009688', '#ccc'];
-			var option = {
-				color: colors,
-				tooltip: {
-					trigger: 'none',
-					axisPointer: {
-						type: 'cross'
-					}
-				},
-				legend: {},
-				grid: {
-					top: 70,
-					bottom: 50
-				},
-				title: {
-					left: 'center',
-					text: '文章《@Model.Title》最近访问趋势'
-				},
-				xAxis: xSeries.map(function (item, index) {
-					return {
-						type: 'category',
-						axisTick: {
-							alignWithLabel: true
-						},
-						axisLine: {
-							onZero: false,
-							lineStyle: {
-								color: colors[index]
-							}
-						},
-						axisPointer: {
-							label: {
-								formatter: function (params) {
-									return params.value + (params.seriesData.length ? ' 访问量:' + params.seriesData[0].data + ",UV:" + params.seriesData[1].data : '');
-								}
-							}
-						},
-						data: item
-					}
-				}),
-				yAxis: [
-					{
-						type: 'value'
-					}
-				],
-				series: yCountSeries.map(function (item, index) {
-					return {
-						type: 'line',
-						symbol: 'none',
-						xAxisIndex: index,
-						data: item,
-						lineStyle: {
-							type: index === 1 ? 'dashed' : ""
-						},
-						markPoint: {
-							data: [
-								{ type: 'max', name: '最大值' },
-								{ type: 'min', name: '最小值' }
-							]
-						},
-						markLine: {
-							data: [
-								{ type: 'average', name: '平均值' }
-							]
-						}
-					}
-				}).concat(yUvSeries.map(function (item, index) {
-					return {
-						type: 'line',
-						symbol: 'none',
-						xAxisIndex: index,
-						areaStyle: {},
-						data: item,
-						lineStyle: {
-							type: index === 1 ? 'dashed' : ""
-						}
-					}
-				}))
-			};
-			myChart.setOption(option);
-		});
-	}
+    layui.use("form", function() {
+        var form = layui.form;
+        form.on("select(period)", function (data) {
+            var chartDom = document.getElementById('chart');
+            echarts.init(chartDom).dispose();
+            showCharts();
+        });
+    });
+    showCharts();
+    function showCharts() {
+        var period = document.getElementById("period").value;
+        window.fetch(`/@Model.Id/records-chart?compare=${period > 0}&period=${period}`, {
+            credentials: 'include',
+            method: 'GET',
+            mode: 'cors'
+        }).then(function (response) {
+            return response.json();
+        }).then(function (res) {
+            var xSeries = [];
+            var yCountSeries = [];
+            var yUvSeries = [];
+            for (let series of res) {
+                var x = [];
+                var yCount = [];
+                var yUV = [];
+                for (let item of series) {
+                    x.push(new Date(Date.parse(item.Date)).toLocaleDateString());
+                    yCount.push(item.Count);
+                    yUV.push(item.UV);
+                }
+                xSeries.push(x);
+                yCountSeries.push(yCount);
+                yUvSeries.push(yUV);
+            }
+            var chartDom = document.getElementById('chart');
+            var myChart = echarts.init(chartDom);
+            const colors = ['#009688', '#ccc'];
+            var option = {
+                color: colors,
+                tooltip: {
+                    trigger: 'none',
+                    axisPointer: {
+                        type: 'cross'
+                    }
+                },
+                legend: {},
+                grid: {
+                    top: 70,
+                    bottom: 50
+                },
+                title: {
+                    left: 'center',
+                    text: '文章《@Model.Title》最近访问趋势'
+                },
+                xAxis: xSeries.map(function (item, index) {
+                    return {
+                        type: 'category',
+                        axisTick: {
+                            alignWithLabel: true
+                        },
+                        axisLine: {
+                            onZero: false,
+                            lineStyle: {
+                                color: colors[index]
+                            }
+                        },
+                        axisPointer: {
+                            label: {
+                                formatter: function (params) {
+                                    return params.value + (params.seriesData.length ? ' 访问量:' + params.seriesData[0].data + ",UV:" + params.seriesData[1].data : '');
+                                }
+                            }
+                        },
+                        data: item
+                    }
+                }),
+                yAxis: [
+                    {
+                        type: 'value'
+                    }
+                ],
+                series: yCountSeries.map(function (item, index) {
+                    return {
+                        type: 'line',
+                        symbol: 'none',
+                        xAxisIndex: index,
+                        data: item,
+                        lineStyle: {
+                            type: index === 1 ? 'dashed' : ""
+                        },
+                        markPoint: {
+                            data: [
+                                { type: 'max', name: '最大值' },
+                                { type: 'min', name: '最小值' }
+                            ]
+                        },
+                        markLine: {
+                            data: [
+                                { type: 'average', name: '平均值' }
+                            ]
+                        }
+                    }
+                }).concat(yUvSeries.map(function (item, index) {
+                    return {
+                        type: 'line',
+                        symbol: 'none',
+                        xAxisIndex: index,
+                        areaStyle: {},
+                        data: item,
+                        lineStyle: {
+                            type: index === 1 ? 'dashed' : ""
+                        }
+                    }
+                }))
+            };
+            myChart.setOption(option);
+        });
+    }
 </script>

+ 8 - 8
src/Masuit.MyBlogs.Core/Views/Tools/Address.cshtml

@@ -48,15 +48,15 @@
         <div id="allmap"></div>
         <script type="text/javascript" src="https://api.map.baidu.com/api?v=2.0&ak=89772e94509a9b903724e247cbc175c2"></script>
         <script>
-	    var map = new BMap.Map("allmap"); // 创建Map实例,设置地图允许的最小/大级别
+        var map = new BMap.Map("allmap"); // 创建Map实例,设置地图允许的最小/大级别
 
-	    map.centerAndZoom(new BMap.Point(@(Model.Lng), @(Model.Lat)), 15);
-	    map.enableScrollWheelZoom(true);
-	    var new_point = new BMap.Point(@(Model.Lng), @(Model.Lat));
-	    var marker = new BMap.Marker(new_point); // 创建标注
-	    map.addOverlay(marker); // 将标注添加到地图中
-	    marker.setAnimation(BMAP_ANIMATION_BOUNCE); //跳动的动画
-	    map.panTo(new_point);
+        map.centerAndZoom(new BMap.Point(@(Model.Lng), @(Model.Lat)), 15);
+        map.enableScrollWheelZoom(true);
+        var new_point = new BMap.Point(@(Model.Lng), @(Model.Lat));
+        var marker = new BMap.Marker(new_point); // 创建标注
+        map.addOverlay(marker); // 将标注添加到地图中
+        marker.setAnimation(BMAP_ANIMATION_BOUNCE); //跳动的动画
+        map.panTo(new_point);
         </script>
     }
 </div>

+ 16 - 0
src/Masuit.MyBlogs.Core/Views/Tools/Loan.cshtml

@@ -0,0 +1,16 @@
+@using Masuit.Tools.Models
+@using Masuit.MyBlogs.Core.Views.Tools
+@model Location
+@{
+    ViewBag.Title = "房贷多次提前还款试算模型计算器";
+    Layout = "~/Views/Shared/_Layout.cshtml";
+}
+<div class="container">
+    <ol class="cd-breadcrumb triangle">
+        <li><a asp-controller="Home" asp-action="Index">首页</a></li>
+        <li class="current">
+            <em>@ViewBag.Title</em>
+        </li>
+    </ol>
+</div>
+@(await Html.RenderComponentAsync<Loan>(RenderMode.ServerPrerendered, new { IP = Context.Connection.RemoteIpAddress.ToString() }))

+ 289 - 0
src/Masuit.MyBlogs.Core/Views/Tools/Loan.razor

@@ -0,0 +1,289 @@
+@using Masuit.Tools.Models
+@using System.Globalization
+@using CacheManager.Core
+@implements IAsyncDisposable
+@inject ICacheManager<HashSet<string>> CacheManager
+<div class="container">
+    <p>当前<span class="text-red online">@online</span>人正在使用本功能</p>
+    <p class="text-red">温馨提示:支持多次提前还款和多次调整利率,同时支持提前还款时变更贷款方式和缩短年限,如有利率调整或提前还款计划,因银行计算受实时利率或提前还款违约金影响,本试算模型的计算结果和银行结果大约有1‰的误差,结果仅供参考,请以银行结果为准</p>
+    <div class="panel panel-info">
+        <div class="panel-heading">贷款基本信息</div>
+        <div class="panel-body">
+            <div class="input-group">
+                <label class="input-group-addon" for="loan">贷款金额</label>
+                <input @bind="LoanAmount" class="form-control" id="loan" placeholder="贷款金额" type="number" min="10">
+                <span class="input-group-addon">万元</span>
+            </div>
+            <div class="input-group">
+                <label class="input-group-addon" for="rate">发放利率</label>
+                <input @bind="Rate" class="form-control" id="rate" placeholder="贷款发放利率" type="number" step="0.1" min="1" max="24">
+                <span class="input-group-addon">%</span>
+            </div>
+            <div class="input-group">
+                <label class="input-group-addon" for="year">贷款年限</label>
+                <input @bind="Year" class="form-control" id="year" placeholder="贷款年限" type="number" max="30" min="2">
+                <span class="input-group-addon">年</span>
+            </div>
+            <div class="input-group">
+                <label class="input-group-addon" for="start">贷款时间</label>
+                <input @bind="Start" class="form-control" id="start" placeholder="贷款发放时间" type="date">
+            </div>
+            <div class="input-group">
+                <label class="input-group-addon">贷款方式</label>
+                <select @bind="Type" class="form-control">
+                    <option value="@LoanType.EquivalentInterest">等额本息</option>
+                    <option value="@LoanType.EquivalentPrincipal">等额本金</option>
+                </select>
+            </div>
+        </div>
+    </div>
+    <div class="panel panel-danger">
+        <div class="panel-heading">利率调整计划</div>
+        <div class="panel-body">
+            <table class="table table-bordered table-striped">
+                <thead class="firstRow">
+                <tr>
+                    <td>调整时间</td>
+                    <td>利率</td>
+                    <td>操作</td>
+                </tr>
+                </thead>
+                <tbody>
+                @foreach (var (key, value) in RateAdjustments) {
+                    <tr>
+                        <td>@key.ToString("yyyy-MM-dd")</td>
+                        <td>@value?.ToString("P")</td>
+                        <td>
+                            <button class="btn btn-danger" @onclick="() => RateAdjustments.Remove(key)">移除</button>
+                        </td>
+                    </tr>
+                }
+                <tr>
+                    <td>
+                        <input @bind="AdjTime" class="form-control" placeholder="利率调整时间" type="date">
+                    </td>
+                        <td>
+                            <div class="input-group">
+                                <input @bind="RateAdj" class="form-control" placeholder="4.2" type="number" step="0.1" min="1" max="24">
+                                <span class="input-group-addon">%</span>
+                            </div>
+                    </td>
+                    <td>
+                        @if (RateAdj is > 0 and < 20) {
+                            <button class="btn btn-info" @onclick="() => RateAdjustments.AddOrUpdate(AdjTime, RateAdj / 100, RateAdj / 100)">添加</button>
+                        }
+                    </td>
+                </tr>
+                </tbody>
+            </table>
+        </div>
+    </div>
+    <div class="panel panel-success">
+        <div class="font-weight-bold panel-heading">提前还款计划</div>
+        <div class="panel-body">
+            <table class="table table-bordered table-striped">
+                <thead class="firstRow">
+                <tr>
+                    <td>提前还款时间</td>
+                    <td>提前还款额</td>
+                    <td>是否缩短期限</td>
+                    <td>变更贷款方式</td>
+                    <td>操作</td>
+                </tr>
+                </thead>
+                <tbody>
+                @foreach (var item in PrepaymentOptions) {
+                    <tr>
+                        <td>@item.Date.ToString("yyyy-MM-dd")</td>
+                        <td>@item.Amount.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                        <td>@(item.ReducePeriod?"是":"否")</td>
+                        <td>@item.ChangeType?.GetDescription()</td>
+                        <td>
+                            <button class="btn btn-danger" @onclick="() => PrepaymentOptions.Remove(item)">移除</button>
+                        </td>
+                    </tr>
+                }
+                <tr>
+                    <td>
+                        <input @bind="PrepaymentDate" class="form-control" placeholder="提前还款时间" type="date">
+                    </td>
+                    <td>
+                        <div class="input-group">
+                            <input @bind="PrepaymentAmount" class="form-control" placeholder="100000" type="number" min="5" max="@LoanAmount">
+                            <span class="input-group-addon">万元</span>
+                        </div>
+                    </td>
+                    <td>
+                        <input @bind="PrepaymentReducePeriod" type="checkbox">
+                    </td>
+                    <td>
+                        <select @bind="PrepaymentLoanType" class="form-control">
+                            <option value="@LoanType.EquivalentInterest">等额本息</option>
+                            <option value="@LoanType.EquivalentPrincipal">等额本金</option>
+                        </select>
+                    </td>
+                    <td>
+                        @if (PrepaymentAmount > 0) {
+                            <button class="btn btn-info" @onclick="() => PrepaymentOptions.Add(new PrepaymentOption(PrepaymentDate, PrepaymentAmount * 10000, PrepaymentReducePeriod, PrepaymentLoanType))">添加</button>
+                        }
+                    </td>
+                </tr>
+                </tbody>
+            </table>
+            <p class="text-red">温馨提示:【缩短期限】选项实际银行政策不允许直接调整,需要重新变更贷款合同,该选项勾选可能会和银行新贷款合同的还款计划相差较大,视银行具体情况而定。【缩短期限】与【变更贷款方式】选项同时发生变化时,仅生效变更贷款方式。</p>
+        </div>
+    </div>
+    <button class="btn btn-info btn-lg" @onclick="Calc">开始计算</button>
+    @if (LoanResult.Plans.Any()) {
+        <div class="panel panel-primary">
+            <div class="panel-heading">汇总摘要</div>
+            <div class="panel-body">
+                <table class="table table-bordered table-striped">
+                    <tbody>
+                    <tr>
+                        <td>总利息</td>
+                        <td>@LoanResult.TotalInterest.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                    </tr>
+                    <tr>
+                        <td>实际支付利息</td>
+                        <td>@LoanResult.ActualInterest.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                    </tr>
+                    <tr>
+                        <td>提前还款节省利息</td>
+                        <td>@LoanResult.SavedInterest.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                    </tr>
+                    <tr>
+                        <td>总提前还款</td>
+                        <td>@LoanResult.TotalRepayment.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                    </tr>
+                    <tr>
+                        <td>实际还款总额</td>
+                        <td>@LoanResult.ActualPayment.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                    </tr>
+                    <tr>
+                        <td>总还款期数</td>
+                        <td>@LoanResult.Plans.Count 期</td>
+                    </tr>
+                    </tbody>
+                </table>
+            </div>
+        </div>
+
+        <div class="panel panel-info">
+            <div class="panel-heading">还款计划明细:</div>
+            <div class="panel-body">
+                <table class="table table-bordered table-striped">
+                    <thead class="firstRow">
+                    <tr>
+                        <td>期数</td>
+                        <td>还款日</td>
+                        <td>月供</td>
+                        <td>年利率</td>
+                        <td>月还利息</td>
+                        <td>月还本金</td>
+                        <td>当期提前还款额</td>
+                        <td>每期剩余本金</td>
+                        <td>贷款类型</td>
+                    </tr>
+                    </thead>
+                    <tbody>
+                    @foreach (var item in LoanResult.Plans) {
+                        <tr>
+                            <td>@item.Period</td>
+                            <td>@item.Date.ToString("yyyy-MM-dd")</td>
+                            <td>@item.Payment.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                            <td>@item.Rate.ToString("P")</td>
+                            <td>@item.Interest.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                            <td>@item.Amount.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                            <td>@item.Repayment.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                            <td>@item.Balance.ToString("C",CultureInfo.CreateSpecificCulture("zh-CN"))</td>
+                            <td>@item.LoanType.GetDescription()</td>
+                        </tr>
+                    }
+                    </tbody>
+                </table>
+            </div>
+        </div>
+    }
+</div>
+
+@code {
+
+    int online;
+    Timer _timer;
+
+    [Parameter]
+    public string IP { get; set; }
+
+    public decimal LoanAmount { get; set; } = 100;
+    public decimal Rate { get; set; } = 4.3m;
+    public int Year { get; set; } = 20;
+    public int Period => Year * 12;
+    public DateTime Start { get; set; } = DateTime.Today;
+    public LoanType Type { get; set; }
+    public Dictionary<DateTime, decimal?> RateAdjustments = new();
+    public List<PrepaymentOption> PrepaymentOptions = new();
+    public LoanResult LoanResult { get; set; } = new(0, new List<PaymentPlan>());
+
+    public decimal RateAdj { get; set; } = 4;
+    public DateTime AdjTime { get; set; } = DateTime.Today;
+
+    public DateTime PrepaymentDate = DateTime.Today;
+    public decimal PrepaymentAmount=5;
+    public bool PrepaymentReducePeriod { get; set; }
+    public LoanType PrepaymentLoanType { get; set; }
+
+    public void Calc() {
+        LoanResult = new LoanModel(LoanAmount * 10000, Rate / 100, Period, Start, Type) {
+            Prepayments = PrepaymentOptions,
+            RateAdjustments = RateAdjustments
+        }.Payment();
+    }
+
+    protected override void OnInitialized()
+    {
+        try
+        {
+            var key = nameof(Loan);
+            online = CacheManager.AddOrUpdate(key, new HashSet<string>(), set =>
+            {
+                set.Add(IP);
+                return set;
+            }, 3).Count;
+            CacheManager.Expire(key, ExpirationMode.Sliding, TimeSpan.FromMinutes(5));
+            _timer = new Timer(_ =>
+            {
+                try
+                {
+                    online = CacheManager.Get(key)?.Count ?? 0;
+                    InvokeAsync(StateHasChanged);
+                }
+                catch
+                {
+                    // ignored
+                }
+            }, null, 0, 1000);
+        }
+        catch
+        {
+            // ignored
+        }
+    }
+
+    public ValueTask DisposeAsync()
+    {
+        try
+        {
+            online = CacheManager.AddOrUpdate(nameof(Loan), new HashSet<string>(), set =>
+            {
+                set.Remove(IP);
+                return set;
+            }).Count;
+        }
+        catch
+        {
+            // ignored
+        }
+        return _timer?.DisposeAsync() ?? ValueTask.CompletedTask;
+    }
+}