mirror of
https://github.com/2dust/v2rayN.git
synced 2025-12-01 04:03:00 +00:00
feat(ui/sub): show subscription traffic and expiry progress + auto refresh
- Add subscription usage/expiry display at Profiles top (WPF/Avalonia) - Parse `Subscription-Userinfo` header when updating subscriptions - On startup and per-subscription auto-refresh, fetch header to update UI - Persist last known usage to guiConfigs/SubUsage.json (atomic write) - Unify header fetching in SubscriptionInfoManager - Fix units (B/KB/MB/GB/TB) - Reduce spacing in progress layout - Thread-safe map updates + snapshot save Refs: 2dust/v2rayN#8136
This commit is contained in:
parent
0c0ecc359b
commit
d04b8fa018
12 changed files with 565 additions and 12 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -399,3 +399,6 @@ FodyWeavers.xsd
|
|||
# JetBrains Rider
|
||||
.idea/
|
||||
*.sln.iml
|
||||
|
||||
# AI Tools
|
||||
AGENTS.md
|
||||
|
|
|
|||
|
|
@ -171,22 +171,22 @@ public class Utils
|
|||
|
||||
public static string HumanFy(long amount)
|
||||
{
|
||||
if (amount <= 0)
|
||||
{
|
||||
return $"{amount:f1} B";
|
||||
}
|
||||
|
||||
string[] units = ["KB", "MB", "GB", "TB", "PB"];
|
||||
// Bytes → KB → MB → GB → TB → PB
|
||||
string[] units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
var unitIndex = 0;
|
||||
double size = amount;
|
||||
double size = amount < 0 ? 0 : (double)amount;
|
||||
|
||||
// Loop and divide by 1024 until a suitable unit is found
|
||||
while (size >= 1024 && unitIndex < units.Length - 1)
|
||||
{
|
||||
size /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
// For bytes, show integer without decimal; for others keep 1 decimal
|
||||
if (unitIndex == 0)
|
||||
{
|
||||
return $"{(long)size} {units[unitIndex]}";
|
||||
}
|
||||
return $"{size:f1} {units[unitIndex]}";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Reactive;
|
||||
using ServiceLib.Models;
|
||||
|
||||
namespace ServiceLib.Events;
|
||||
|
||||
|
|
@ -29,4 +30,7 @@ public static class AppEvents
|
|||
public static readonly EventChannel<Unit> TestServerRequested = new();
|
||||
public static readonly EventChannel<Unit> InboundDisplayRequested = new();
|
||||
public static readonly EventChannel<ESysProxyType> SysProxyChangeRequested = new();
|
||||
|
||||
// Fired when subscription usage/expiry info updates
|
||||
public static readonly EventChannel<SubscriptionUsageInfo> SubscriptionInfoUpdated = new();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
using ServiceLib.Manager;
|
||||
|
||||
namespace ServiceLib.Handler;
|
||||
|
||||
public static class SubscriptionHandler
|
||||
|
|
@ -119,8 +121,9 @@ public static class SubscriptionHandler
|
|||
|
||||
private static async Task<string> DownloadMainSubscription(Config config, SubItem item, bool blProxy, DownloadService downloadHandle)
|
||||
{
|
||||
// Prepare subscription URL and download directly
|
||||
var url = Utils.GetPunycode(item.Url.TrimEx());
|
||||
// Prepare URLs
|
||||
var originalUrl = Utils.GetPunycode(item.Url.TrimEx());
|
||||
var url = originalUrl;
|
||||
|
||||
// If conversion is needed
|
||||
if (item.ConvertTarget.IsNotEmpty())
|
||||
|
|
@ -129,7 +132,7 @@ public static class SubscriptionHandler
|
|||
? Global.SubConvertUrls.FirstOrDefault()
|
||||
: config.ConstItem.SubConvertUrl;
|
||||
|
||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(url));
|
||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(originalUrl));
|
||||
|
||||
if (!url.Contains("target="))
|
||||
{
|
||||
|
|
@ -142,7 +145,36 @@ public static class SubscriptionHandler
|
|||
}
|
||||
}
|
||||
|
||||
// Download and return result directly
|
||||
// 1) Fetch final URL with headers (content + header if any)
|
||||
var (userHeader, content) = await downloadHandle.TryGetWithHeaders(url, blProxy, item.UserAgent);
|
||||
var hadHeader = false;
|
||||
if (userHeader.IsNotEmpty())
|
||||
{
|
||||
SubscriptionInfoManager.Instance.UpdateFromHeader(item.Id, new[] { userHeader });
|
||||
hadHeader = true;
|
||||
}
|
||||
|
||||
// 2) If no header captured and using converter, try original URL only for header
|
||||
if (!hadHeader && item.ConvertTarget.IsNotEmpty())
|
||||
{
|
||||
try
|
||||
{
|
||||
var (userHeader2, _) = await downloadHandle.TryGetWithHeaders(originalUrl, blProxy, item.UserAgent, timeoutSeconds: 10);
|
||||
if (userHeader2.IsNotEmpty())
|
||||
{
|
||||
SubscriptionInfoManager.Instance.UpdateFromHeader(item.Id, new[] { userHeader2 });
|
||||
hadHeader = true;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
if (content.IsNotEmpty())
|
||||
{
|
||||
return content!;
|
||||
}
|
||||
|
||||
// 3) Fallback: plain download string
|
||||
return await DownloadSubscriptionContent(downloadHandle, url, blProxy, item.UserAgent);
|
||||
}
|
||||
|
||||
|
|
|
|||
208
v2rayN/ServiceLib/Manager/SubscriptionInfoManager.cs
Normal file
208
v2rayN/ServiceLib/Manager/SubscriptionInfoManager.cs
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
using System.Text.RegularExpressions;
|
||||
using ServiceLib.Common;
|
||||
using ServiceLib.Models;
|
||||
|
||||
namespace ServiceLib.Manager;
|
||||
|
||||
public sealed class SubscriptionInfoManager
|
||||
{
|
||||
private static readonly Lazy<SubscriptionInfoManager> _instance = new(() => new());
|
||||
public static SubscriptionInfoManager Instance => _instance.Value;
|
||||
|
||||
private readonly Dictionary<string, SubscriptionUsageInfo> _map = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly string _storeFile = Utils.GetConfigPath("SubUsage.json");
|
||||
private readonly object _saveLock = new();
|
||||
private readonly object _mapLock = new();
|
||||
|
||||
private SubscriptionInfoManager()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(_storeFile))
|
||||
{
|
||||
var txt = File.ReadAllText(_storeFile);
|
||||
var data = JsonUtils.Deserialize<Dictionary<string, SubscriptionUsageInfo>>(txt);
|
||||
if (data != null)
|
||||
{
|
||||
foreach (var kv in data)
|
||||
{
|
||||
if (kv.Value != null)
|
||||
{
|
||||
_map[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public SubscriptionUsageInfo? Get(string subId)
|
||||
{
|
||||
if (subId.IsNullOrEmpty()) return null;
|
||||
lock (_mapLock)
|
||||
{
|
||||
_map.TryGetValue(subId, out var info);
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(string subId, SubscriptionUsageInfo info)
|
||||
{
|
||||
if (subId.IsNullOrEmpty() || info == null) return;
|
||||
info.SubId = subId;
|
||||
lock (_mapLock)
|
||||
{
|
||||
_map[subId] = info;
|
||||
}
|
||||
AppEvents.SubscriptionInfoUpdated.Publish(info);
|
||||
SaveCopy();
|
||||
}
|
||||
|
||||
public void Clear(string subId)
|
||||
{
|
||||
if (subId.IsNullOrEmpty()) return;
|
||||
lock (_mapLock)
|
||||
{
|
||||
_map.Remove(subId);
|
||||
}
|
||||
SaveCopy();
|
||||
}
|
||||
|
||||
// Common subscription header: "Subscription-Userinfo: upload=123; download=456; total=789; expire=1700000000"
|
||||
public void UpdateFromHeader(string subId, IEnumerable<string>? headerValues)
|
||||
{
|
||||
if (headerValues == null) return;
|
||||
var raw = headerValues.FirstOrDefault();
|
||||
if (raw.IsNullOrEmpty()) return;
|
||||
|
||||
var info = ParseUserinfo(raw);
|
||||
if (info != null)
|
||||
{
|
||||
Update(subId, info);
|
||||
}
|
||||
}
|
||||
|
||||
private static SubscriptionUsageInfo? ParseUserinfo(string raw)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = new SubscriptionUsageInfo();
|
||||
|
||||
var rx = new Regex(@"(?i)(upload|download|total|expire)\s*=\s*([0-9]+)", RegexOptions.Compiled);
|
||||
foreach (Match m in rx.Matches(raw))
|
||||
{
|
||||
var key = m.Groups[1].Value.ToLowerInvariant();
|
||||
if (!long.TryParse(m.Groups[2].Value, out var val)) continue;
|
||||
switch (key)
|
||||
{
|
||||
case "upload": info.Upload = val; break;
|
||||
case "download": info.Download = val; break;
|
||||
case "total": info.Total = val; break;
|
||||
case "expire": info.ExpireEpoch = val; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (info.Total == 0 && info.Upload == 0 && info.Download == 0 && info.ExpireEpoch == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return info;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveCopy()
|
||||
{
|
||||
try
|
||||
{
|
||||
Dictionary<string, SubscriptionUsageInfo> snapshot;
|
||||
lock (_mapLock)
|
||||
{
|
||||
snapshot = new(_map);
|
||||
}
|
||||
var txt = JsonUtils.Serialize(snapshot, true, true);
|
||||
var tmp = _storeFile + ".tmp";
|
||||
lock (_saveLock)
|
||||
{
|
||||
File.WriteAllText(tmp, txt);
|
||||
if (File.Exists(_storeFile))
|
||||
{
|
||||
File.Replace(tmp, _storeFile, null);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Move(tmp, _storeFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async Task FetchHeadersForAll(Config config)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subs = await AppManager.Instance.SubItems();
|
||||
if (subs is not { Count: > 0 }) return;
|
||||
foreach (var s in subs)
|
||||
{
|
||||
await FetchHeaderForSub(config, s);
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async Task FetchHeaderForSub(Config config, SubItem s)
|
||||
{
|
||||
try
|
||||
{
|
||||
var originalUrl = Utils.GetPunycode(s.Url.TrimEx());
|
||||
if (originalUrl.IsNullOrEmpty()) return;
|
||||
|
||||
string url = originalUrl;
|
||||
if (s.ConvertTarget.IsNotEmpty())
|
||||
{
|
||||
var subConvertUrl = config.ConstItem.SubConvertUrl.IsNullOrEmpty()
|
||||
? Global.SubConvertUrls.FirstOrDefault()
|
||||
: config.ConstItem.SubConvertUrl;
|
||||
if (subConvertUrl.IsNotEmpty())
|
||||
{
|
||||
url = string.Format(subConvertUrl!, Utils.UrlEncode(originalUrl));
|
||||
if (!url.Contains("target=")) url += string.Format("&target={0}", s.ConvertTarget);
|
||||
if (!url.Contains("config=")) url += string.Format("&config={0}", Global.SubConvertConfig.FirstOrDefault());
|
||||
}
|
||||
}
|
||||
|
||||
var dl = new DownloadService();
|
||||
// via proxy then direct
|
||||
foreach (var blProxy in new[] { true, false })
|
||||
{
|
||||
var (userHeader, _) = await dl.TryGetWithHeaders(url, blProxy, s.UserAgent, timeoutSeconds: 8);
|
||||
if (userHeader.IsNotEmpty())
|
||||
{
|
||||
UpdateFromHeader(s.Id, new[] { userHeader });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// fallback to original url if converted
|
||||
if (s.ConvertTarget.IsNotEmpty())
|
||||
{
|
||||
foreach (var blProxy in new[] { true, false })
|
||||
{
|
||||
var (userHeader2, _) = await dl.TryGetWithHeaders(originalUrl, blProxy, s.UserAgent, timeoutSeconds: 6);
|
||||
if (userHeader2.IsNotEmpty())
|
||||
{
|
||||
UpdateFromHeader(s.Id, new[] { userHeader2 });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
|
|
@ -79,6 +79,8 @@ public class TaskManager
|
|||
Logging.SaveLog($"Update subscription end. {msg}");
|
||||
}
|
||||
});
|
||||
// 同步刷新该订阅的用量/到期信息
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, item); } catch { }
|
||||
item.UpdateTime = updateTime;
|
||||
await ConfigHandler.AddSubItem(_config, item);
|
||||
await Task.Delay(1000);
|
||||
|
|
|
|||
48
v2rayN/ServiceLib/Models/SubscriptionUsageInfo.cs
Normal file
48
v2rayN/ServiceLib/Models/SubscriptionUsageInfo.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
namespace ServiceLib.Models;
|
||||
|
||||
public class SubscriptionUsageInfo
|
||||
{
|
||||
public string SubId { get; set; } = string.Empty;
|
||||
|
||||
// Bytes
|
||||
public long Upload { get; set; }
|
||||
public long Download { get; set; }
|
||||
public long Total { get; set; }
|
||||
|
||||
// Unix epoch seconds; 0 if unknown
|
||||
public long ExpireEpoch { get; set; }
|
||||
|
||||
public long UsedBytes => Math.Max(0, Upload + Download);
|
||||
|
||||
public int UsagePercent
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Total <= 0) return -1;
|
||||
var p = (int)Math.Round(UsedBytes * 100.0 / Total);
|
||||
return Math.Clamp(p, 0, 100);
|
||||
}
|
||||
}
|
||||
|
||||
public DateTimeOffset? ExpireAt
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ExpireEpoch <= 0) return null;
|
||||
try { return DateTimeOffset.FromUnixTimeSeconds(ExpireEpoch).ToLocalTime(); }
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
|
||||
public int DaysLeft
|
||||
{
|
||||
get
|
||||
{
|
||||
var exp = ExpireAt;
|
||||
if (exp == null) return -1;
|
||||
var days = (int)Math.Ceiling((exp.Value - DateTimeOffset.Now).TotalDays);
|
||||
return Math.Max(days, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -133,6 +133,68 @@ public class DownloadService
|
|||
return null;
|
||||
}
|
||||
|
||||
// Best-effort: get specific header (Subscription-Userinfo) and content in one request.
|
||||
// Returns: (userinfoHeaderValue, content)
|
||||
public async Task<(string? userInfoHeader, string? content)> TryGetWithHeaders(string url, bool blProxy, string userAgent, int timeoutSeconds = 15)
|
||||
{
|
||||
try
|
||||
{
|
||||
SetSecurityProtocol(AppManager.Instance.Config.GuiItem.EnableSecurityProtocolTls13);
|
||||
var webProxy = await GetWebProxy(blProxy);
|
||||
var handler = new SocketsHttpHandler()
|
||||
{
|
||||
Proxy = webProxy,
|
||||
UseProxy = webProxy != null,
|
||||
AllowAutoRedirect = true
|
||||
};
|
||||
using var client = new HttpClient(handler);
|
||||
|
||||
if (userAgent.IsNullOrEmpty())
|
||||
{
|
||||
userAgent = Utils.GetVersion(false);
|
||||
}
|
||||
client.DefaultRequestHeaders.UserAgent.TryParseAdd(userAgent);
|
||||
|
||||
Uri uri = new(url);
|
||||
if (uri.UserInfo.IsNotEmpty())
|
||||
{
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", Utils.Base64Encode(uri.UserInfo));
|
||||
}
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cts.Token);
|
||||
|
||||
// Try read target header regardless of status code
|
||||
string? userHeader = null;
|
||||
if (resp.Headers != null)
|
||||
{
|
||||
if (resp.Headers.TryGetValues("Subscription-Userinfo", out var vals) ||
|
||||
resp.Headers.TryGetValues("subscription-userinfo", out vals))
|
||||
{
|
||||
userHeader = vals?.FirstOrDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Read content only on success; otherwise empty to trigger fallback
|
||||
string? content = null;
|
||||
if (resp.IsSuccessStatusCode)
|
||||
{
|
||||
content = await resp.Content.ReadAsStringAsync(cts.Token);
|
||||
}
|
||||
return (userHeader, content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logging.SaveLog(_tag, ex);
|
||||
Error?.Invoke(this, new ErrorEventArgs(ex));
|
||||
if (ex.InnerException != null)
|
||||
{
|
||||
Error?.Invoke(this, new ErrorEventArgs(ex.InnerException));
|
||||
}
|
||||
}
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DownloadString
|
||||
/// </summary>
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ using System.Reactive.Concurrency;
|
|||
using System.Reactive.Linq;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Fody.Helpers;
|
||||
using ServiceLib.Manager;
|
||||
|
||||
namespace ServiceLib.ViewModels;
|
||||
|
||||
|
|
@ -274,8 +275,16 @@ public class MainWindowViewModel : MyReactiveObject
|
|||
|
||||
BlReloadEnabled = true;
|
||||
await Reload();
|
||||
|
||||
// 开机自动爬取所有订阅的用量/到期头信息(后台执行)
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeadersForAll(_config); } catch { }
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
|
||||
#endregion Init
|
||||
|
||||
#region Actions
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ using DynamicData;
|
|||
using DynamicData.Binding;
|
||||
using ReactiveUI;
|
||||
using ReactiveUI.Fody.Helpers;
|
||||
using ServiceLib.Manager;
|
||||
using ServiceLib.Models;
|
||||
// using ServiceLib.Services; // covered by GlobalUsings
|
||||
|
||||
namespace ServiceLib.ViewModels;
|
||||
|
||||
|
|
@ -34,6 +37,22 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
[Reactive]
|
||||
public SubItem SelectedSub { get; set; }
|
||||
|
||||
// Subscription usage/expiry display
|
||||
[Reactive]
|
||||
public bool BlSubInfoVisible { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public int SubUsagePercent { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SubUsageText { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public int SubExpirePercent { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public string SubExpireText { get; set; }
|
||||
|
||||
[Reactive]
|
||||
public SubItem SelectedMoveToGroup { get; set; }
|
||||
|
||||
|
|
@ -254,6 +273,17 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Subscribe(async _ => await RefreshSubscriptions());
|
||||
|
||||
AppEvents.SubscriptionInfoUpdated
|
||||
.AsObservable()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Subscribe(async info => await UpdateSubInfoDisplay(info));
|
||||
|
||||
// 核心Reload后,再尝试抓一次头,避免启动时代理未就绪导致不显示
|
||||
AppEvents.ReloadRequested
|
||||
.AsObservable()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
.Subscribe(async _ => await TryFetchSubInfoHeaderForSelected());
|
||||
|
||||
AppEvents.DispatcherStatisticsRequested
|
||||
.AsObservable()
|
||||
.ObserveOn(RxApp.MainThreadScheduler)
|
||||
|
|
@ -275,6 +305,12 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
SelectedSub = new();
|
||||
SelectedMoveToGroup = new();
|
||||
|
||||
BlSubInfoVisible = true;
|
||||
SubUsagePercent = -1;
|
||||
SubExpirePercent = -1;
|
||||
SubUsageText = string.Empty;
|
||||
SubExpireText = string.Empty;
|
||||
|
||||
await RefreshSubscriptions();
|
||||
//await RefreshServers();
|
||||
}
|
||||
|
|
@ -354,6 +390,97 @@ public class ProfilesViewModel : MyReactiveObject
|
|||
await RefreshServers();
|
||||
|
||||
await _updateView?.Invoke(EViewAction.ProfilesFocus, null);
|
||||
|
||||
// Update subscription info area for selected sub
|
||||
await UpdateSubInfoDisplay(null);
|
||||
}
|
||||
|
||||
private async Task UpdateSubInfoDisplay(SubscriptionUsageInfo? pushed)
|
||||
{
|
||||
try
|
||||
{
|
||||
var subId = SelectedSub?.Id;
|
||||
if (subId.IsNullOrEmpty())
|
||||
{
|
||||
// 保持显示,但用占位
|
||||
SubUsagePercent = 0;
|
||||
SubExpirePercent = 0;
|
||||
SubUsageText = "—";
|
||||
SubExpireText = "—";
|
||||
return;
|
||||
}
|
||||
|
||||
var info = pushed != null && pushed.SubId == subId
|
||||
? pushed
|
||||
: SubscriptionInfoManager.Instance.Get(subId);
|
||||
|
||||
if (info == null)
|
||||
{
|
||||
// 先用占位显示
|
||||
SubUsagePercent = 0;
|
||||
SubExpirePercent = 0;
|
||||
SubUsageText = "—";
|
||||
SubExpireText = "—";
|
||||
// 尝试即时抓取一次响应头,避免必须“更新订阅”才显示
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, SelectedSub); } catch { }
|
||||
info = SubscriptionInfoManager.Instance.Get(subId);
|
||||
if (info == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
if (info.Total > 0)
|
||||
{
|
||||
SubUsagePercent = info.UsagePercent;
|
||||
SubUsageText = string.Format("{0} / {1} ({2}%)", Utils.HumanFy(info.UsedBytes), Utils.HumanFy(info.Total), SubUsagePercent);
|
||||
}
|
||||
else
|
||||
{
|
||||
SubUsagePercent = -1;
|
||||
SubUsageText = string.Format("{0}", Utils.HumanFy(info.UsedBytes));
|
||||
}
|
||||
|
||||
// Expire
|
||||
if (info.ExpireAt != null)
|
||||
{
|
||||
var daysLeft = info.DaysLeft;
|
||||
SubExpireText = daysLeft >= 0
|
||||
? $"{daysLeft}d — {info.ExpireAt:yyyy-MM-dd}"
|
||||
: $"{info.ExpireAt:yyyy-MM-dd}";
|
||||
|
||||
// 可视化“剩余时间百分比”,基准按天自适应:<=31天用月基准、<=92天用季度、否则按365天
|
||||
if (daysLeft >= 0)
|
||||
{
|
||||
int baseDays = daysLeft <= 31 ? 31 : (daysLeft <= 92 ? 92 : 365);
|
||||
var percentRemain = (int)Math.Round(Math.Min(daysLeft, baseDays) * 100.0 / baseDays);
|
||||
SubExpirePercent = Math.Clamp(percentRemain, 0, 100);
|
||||
}
|
||||
else
|
||||
{
|
||||
SubExpirePercent = -1;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
SubExpireText = string.Empty;
|
||||
SubExpirePercent = -1;
|
||||
}
|
||||
|
||||
BlSubInfoVisible = true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 出错也别隐藏
|
||||
BlSubInfoVisible = true;
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task TryFetchSubInfoHeaderForSelected()
|
||||
{
|
||||
try { await SubscriptionInfoManager.Instance.FetchHeaderForSub(_config, SelectedSub); } catch { }
|
||||
}
|
||||
|
||||
private async Task ServerFilterChanged(bool c)
|
||||
|
|
|
|||
|
|
@ -74,6 +74,20 @@
|
|||
Margin="{StaticResource MarginLr4}"
|
||||
VerticalContentAlignment="Center"
|
||||
Watermark="{x:Static resx:ResUI.MsgServerTitle}" />
|
||||
|
||||
<!-- Subscription usage and expiry -->
|
||||
<StackPanel Margin="{StaticResource MarginLr8}" IsVisible="{Binding BlSubInfoVisible}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" HorizontalAlignment="Left" Margin="0,0,0,2">
|
||||
<TextBlock Width="56" VerticalAlignment="Center" Text="流量" />
|
||||
<ProgressBar Height="16" Minimum="0" Maximum="100" Value="{Binding SubUsagePercent}" Width="240" />
|
||||
<TextBlock VerticalAlignment="Center" Text="{Binding SubUsageText}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" HorizontalAlignment="Left">
|
||||
<TextBlock Width="56" VerticalAlignment="Center" Text="到期" />
|
||||
<ProgressBar Height="16" Minimum="0" Maximum="100" Value="{Binding SubExpirePercent}" Width="240" />
|
||||
<TextBlock VerticalAlignment="Center" Text="{Binding SubExpireText}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
<DataGrid
|
||||
x:Name="lstProfiles"
|
||||
|
|
|
|||
|
|
@ -76,6 +76,50 @@
|
|||
materialDesign:TextFieldAssist.HasClearButton="True"
|
||||
AutomationProperties.Name="{x:Static resx:ResUI.MsgServerTitle}"
|
||||
Style="{StaticResource DefTextBox}" />
|
||||
|
||||
<!-- Subscription usage and expiry -->
|
||||
<StackPanel
|
||||
Margin="{StaticResource MarginLeftRight8}"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Vertical"
|
||||
Visibility="{Binding BlSubInfoVisible, Converter={StaticResource BoolToVisConverter}}">
|
||||
<Grid Margin="0,0,0,2">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Width="120" VerticalAlignment="Center" Text="流量" />
|
||||
<ProgressBar
|
||||
Grid.Column="1"
|
||||
Height="16"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding SubUsagePercent}"
|
||||
Style="{StaticResource MaterialDesignProgressBar}"
|
||||
Margin="4,0,4,0"
|
||||
Width="240" />
|
||||
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding SubUsageText}" Margin="0,0,0,0" />
|
||||
</Grid>
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="0" Width="120" VerticalAlignment="Center" Text="到期" />
|
||||
<ProgressBar
|
||||
Grid.Column="1"
|
||||
Height="16"
|
||||
Minimum="0"
|
||||
Maximum="100"
|
||||
Value="{Binding SubExpirePercent}"
|
||||
Style="{StaticResource MaterialDesignProgressBar}"
|
||||
Margin="4,0,4,0"
|
||||
Width="240" />
|
||||
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding SubExpireText}" Margin="0,0,0,0" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</WrapPanel>
|
||||
<DataGrid
|
||||
x:Name="lstProfiles"
|
||||
|
|
|
|||
Loading…
Reference in a new issue