From 53edd13f8f0c3876e089cda86f3ccd0dee5a6a80 Mon Sep 17 00:00:00 2001
From: GeWuYou <95328647+GeWuYou@users.noreply.github.com>
Date: Sat, 21 Mar 2026 14:42:11 +0800
Subject: [PATCH] =?UTF-8?q?feat(localization):=20=E6=B7=BB=E5=8A=A0?=
=?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96=E6=A0=BC=E5=BC=8F=E5=8C=96=E5=99=A8?=
=?UTF-8?q?=E5=92=8C=E6=95=B0=E5=80=BC=E6=98=BE=E7=A4=BA=E5=8A=9F=E8=83=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 在LocalizationManager中注册内置格式化器包括条件、复数和紧凑数值格式化器
- 实现CompactNumberLocalizationFormatter支持{value:compact}格式化语法
- 添加数值显示扩展方法ToDisplayString和ToCompactString
- 实现NumericDisplayFormatter和NumericSuffixFormatRule数值格式化核心逻辑
- 添加数值格式化选项配置包括小数位数、四舍五入策略等参数
- 为紧凑数值格式化功能添加完整的单元测试覆盖各种数值类型和边界情况
---
.../Numeric/INumericDisplayFormatter.cs | 16 +
.../Utility/Numeric/INumericFormatRule.cs | 22 ++
.../Utility/Numeric/NumericDisplayStyle.cs | 12 +
.../Utility/Numeric/NumericFormatOptions.cs | 52 +++
.../Utility/Numeric/NumericSuffixThreshold.cs | 8 +
.../LocalizationIntegrationTests.cs | 40 +-
.../Utility/NumericDisplayFormatterTests.cs | 129 +++++++
.../Extensions/NumericDisplayExtensions.cs | 30 ++
.../CompactNumberLocalizationFormatter.cs | 167 +++++++++
.../Localization/LocalizationManager.cs | 11 +-
.../Utility/Numeric/NumericDisplay.cs | 58 +++
.../Numeric/NumericDisplayFormatter.cs | 124 +++++++
.../Numeric/NumericSuffixFormatRule.cs | 346 ++++++++++++++++++
13 files changed, 1012 insertions(+), 3 deletions(-)
create mode 100644 GFramework.Core.Abstractions/Utility/Numeric/INumericDisplayFormatter.cs
create mode 100644 GFramework.Core.Abstractions/Utility/Numeric/INumericFormatRule.cs
create mode 100644 GFramework.Core.Abstractions/Utility/Numeric/NumericDisplayStyle.cs
create mode 100644 GFramework.Core.Abstractions/Utility/Numeric/NumericFormatOptions.cs
create mode 100644 GFramework.Core.Abstractions/Utility/Numeric/NumericSuffixThreshold.cs
create mode 100644 GFramework.Core.Tests/Utility/NumericDisplayFormatterTests.cs
create mode 100644 GFramework.Core/Extensions/NumericDisplayExtensions.cs
create mode 100644 GFramework.Core/Localization/Formatters/CompactNumberLocalizationFormatter.cs
create mode 100644 GFramework.Core/Utility/Numeric/NumericDisplay.cs
create mode 100644 GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs
create mode 100644 GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs
diff --git a/GFramework.Core.Abstractions/Utility/Numeric/INumericDisplayFormatter.cs b/GFramework.Core.Abstractions/Utility/Numeric/INumericDisplayFormatter.cs
new file mode 100644
index 0000000..739c905
--- /dev/null
+++ b/GFramework.Core.Abstractions/Utility/Numeric/INumericDisplayFormatter.cs
@@ -0,0 +1,16 @@
+namespace GFramework.Core.Abstractions.Utility.Numeric;
+
+///
+/// 数值显示格式化器接口。
+///
+public interface INumericDisplayFormatter
+{
+ ///
+ /// 将数值格式化为展示字符串。
+ ///
+ /// 数值类型。
+ /// 待格式化的值。
+ /// 格式化选项。
+ /// 格式化后的字符串。
+ string Format(T value, NumericFormatOptions? options = null);
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/Utility/Numeric/INumericFormatRule.cs b/GFramework.Core.Abstractions/Utility/Numeric/INumericFormatRule.cs
new file mode 100644
index 0000000..71d4cb6
--- /dev/null
+++ b/GFramework.Core.Abstractions/Utility/Numeric/INumericFormatRule.cs
@@ -0,0 +1,22 @@
+namespace GFramework.Core.Abstractions.Utility.Numeric;
+
+///
+/// 数值显示规则接口。
+///
+public interface INumericFormatRule
+{
+ ///
+ /// 规则名称。
+ ///
+ string Name { get; }
+
+ ///
+ /// 尝试按当前规则格式化数值。
+ ///
+ /// 数值类型。
+ /// 待格式化的值。
+ /// 格式化选项。
+ /// 输出结果。
+ /// 格式化是否成功。
+ bool TryFormat(T value, NumericFormatOptions options, out string result);
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/Utility/Numeric/NumericDisplayStyle.cs b/GFramework.Core.Abstractions/Utility/Numeric/NumericDisplayStyle.cs
new file mode 100644
index 0000000..088d305
--- /dev/null
+++ b/GFramework.Core.Abstractions/Utility/Numeric/NumericDisplayStyle.cs
@@ -0,0 +1,12 @@
+namespace GFramework.Core.Abstractions.Utility.Numeric;
+
+///
+/// 数值显示风格。
+///
+public enum NumericDisplayStyle
+{
+ ///
+ /// 紧凑缩写风格,例如 1.2K / 3.4M。
+ ///
+ Compact = 0
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/Utility/Numeric/NumericFormatOptions.cs b/GFramework.Core.Abstractions/Utility/Numeric/NumericFormatOptions.cs
new file mode 100644
index 0000000..f27b212
--- /dev/null
+++ b/GFramework.Core.Abstractions/Utility/Numeric/NumericFormatOptions.cs
@@ -0,0 +1,52 @@
+namespace GFramework.Core.Abstractions.Utility.Numeric;
+
+///
+/// 数值格式化选项。
+///
+public sealed record NumericFormatOptions
+{
+ ///
+ /// 显示风格。
+ ///
+ public NumericDisplayStyle Style { get; init; } = NumericDisplayStyle.Compact;
+
+ ///
+ /// 最大保留小数位数。
+ ///
+ public int MaxDecimalPlaces { get; init; } = 1;
+
+ ///
+ /// 最少保留小数位数。
+ ///
+ public int MinDecimalPlaces { get; init; } = 0;
+
+ ///
+ /// 四舍五入策略。
+ ///
+ public MidpointRounding MidpointRounding { get; init; } = MidpointRounding.AwayFromZero;
+
+ ///
+ /// 是否裁剪小数末尾的 0。
+ ///
+ public bool TrimTrailingZeros { get; init; } = true;
+
+ ///
+ /// 小于缩写阈值时是否启用千分位分组。
+ ///
+ public bool UseGroupingBelowThreshold { get; init; }
+
+ ///
+ /// 进入缩写显示的阈值。
+ ///
+ public decimal CompactThreshold { get; init; } = 1000m;
+
+ ///
+ /// 格式提供者。
+ ///
+ public IFormatProvider? FormatProvider { get; init; }
+
+ ///
+ /// 自定义格式规则。
+ ///
+ public INumericFormatRule? Rule { get; init; }
+}
\ No newline at end of file
diff --git a/GFramework.Core.Abstractions/Utility/Numeric/NumericSuffixThreshold.cs b/GFramework.Core.Abstractions/Utility/Numeric/NumericSuffixThreshold.cs
new file mode 100644
index 0000000..49132cd
--- /dev/null
+++ b/GFramework.Core.Abstractions/Utility/Numeric/NumericSuffixThreshold.cs
@@ -0,0 +1,8 @@
+namespace GFramework.Core.Abstractions.Utility.Numeric;
+
+///
+/// 数值缩写阈值定义。
+///
+/// 缩写除数,例如 1000、1000000。
+/// 缩写后缀,例如 K、M。
+public readonly record struct NumericSuffixThreshold(decimal Divisor, string Suffix);
\ No newline at end of file
diff --git a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs
index bfcf15e..5086867 100644
--- a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs
+++ b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs
@@ -49,7 +49,10 @@ public class LocalizationIntegrationTests
{
"game.title": "My Game",
"ui.message.welcome": "Welcome, {playerName}!",
- "status.health": "Health: {current}/{max}"
+ "status.health": "Health: {current}/{max}",
+ "status.gold": "Gold: {gold:compact}",
+ "status.damage": "Damage: {damage:compact:maxDecimals=2}",
+ "status.invalidCompact": "Gold: {gold:compact:maxDecimals=abc}"
}
""");
@@ -57,7 +60,10 @@ public class LocalizationIntegrationTests
{
"game.title": "我的游戏",
"ui.message.welcome": "欢迎, {playerName}!",
- "status.health": "生命值: {current}/{max}"
+ "status.health": "生命值: {current}/{max}",
+ "status.gold": "金币: {gold:compact}",
+ "status.damage": "伤害: {damage:compact:maxDecimals=2}",
+ "status.invalidCompact": "金币: {gold:compact:maxDecimals=abc}"
}
""");
}
@@ -108,6 +114,36 @@ public class LocalizationIntegrationTests
Assert.That(health, Is.EqualTo("Health: 80/100"));
}
+ [Test]
+ public void GetString_WithCompactFormatter_ShouldFormatCorrectly()
+ {
+ var gold = _manager!.GetString("common", "status.gold")
+ .WithVariable("gold", 1_250)
+ .Format();
+
+ Assert.That(gold, Is.EqualTo("Gold: 1.3K"));
+ }
+
+ [Test]
+ public void GetString_WithCompactFormatterArgs_ShouldApplyOptions()
+ {
+ var damage = _manager!.GetString("common", "status.damage")
+ .WithVariable("damage", 1_234)
+ .Format();
+
+ Assert.That(damage, Is.EqualTo("Damage: 1.23K"));
+ }
+
+ [Test]
+ public void GetString_WithInvalidCompactFormatterArgs_ShouldFallbackToDefaultFormatting()
+ {
+ var gold = _manager!.GetString("common", "status.invalidCompact")
+ .WithVariable("gold", 1_250)
+ .Format();
+
+ Assert.That(gold, Is.EqualTo("Gold: 1250"));
+ }
+
[Test]
public void LanguageChange_ShouldTriggerCallback()
{
diff --git a/GFramework.Core.Tests/Utility/NumericDisplayFormatterTests.cs b/GFramework.Core.Tests/Utility/NumericDisplayFormatterTests.cs
new file mode 100644
index 0000000..0f6c44e
--- /dev/null
+++ b/GFramework.Core.Tests/Utility/NumericDisplayFormatterTests.cs
@@ -0,0 +1,129 @@
+using System.Globalization;
+using GFramework.Core.Abstractions.Utility.Numeric;
+using GFramework.Core.Extensions;
+using GFramework.Core.Utility.Numeric;
+
+namespace GFramework.Core.Tests.Utility;
+
+[TestFixture]
+public class NumericDisplayFormatterTests
+{
+ [Test]
+ public void FormatCompact_ShouldReturnPlainText_WhenValueIsBelowThreshold()
+ {
+ var result = NumericDisplay.FormatCompact(950);
+
+ Assert.That(result, Is.EqualTo("950"));
+ }
+
+ [Test]
+ public void FormatCompact_ShouldFormatInt_AsCompactText()
+ {
+ var result = NumericDisplay.FormatCompact(1_200);
+
+ Assert.That(result, Is.EqualTo("1.2K"));
+ }
+
+ [Test]
+ public void FormatCompact_ShouldFormatLong_AsCompactText()
+ {
+ var result = NumericDisplay.FormatCompact(1_000_000L);
+
+ Assert.That(result, Is.EqualTo("1M"));
+ }
+
+ [Test]
+ public void FormatCompact_ShouldFormatDecimal_AsCompactText()
+ {
+ var result = NumericDisplay.FormatCompact(1_234.56m);
+
+ Assert.That(result, Is.EqualTo("1.2K"));
+ }
+
+ [Test]
+ public void FormatCompact_ShouldFormatNegativeValues()
+ {
+ var result = NumericDisplay.FormatCompact(-1_250);
+
+ Assert.That(result, Is.EqualTo("-1.3K"));
+ }
+
+ [Test]
+ public void FormatCompact_ShouldPromoteRoundedBoundary_ToNextSuffix()
+ {
+ var result = NumericDisplay.FormatCompact(999_950);
+
+ Assert.That(result, Is.EqualTo("1M"));
+ }
+
+ [Test]
+ public void Format_ShouldRespectFormatProvider()
+ {
+ var result = NumericDisplay.Format(1_234.5m, new NumericFormatOptions
+ {
+ CompactThreshold = 10_000m,
+ FormatProvider = CultureInfo.GetCultureInfo("de-DE")
+ });
+
+ Assert.That(result, Is.EqualTo("1234,5"));
+ }
+
+ [Test]
+ public void Format_ShouldUseGroupingBelowThreshold_WhenEnabled()
+ {
+ var result = NumericDisplay.Format(12_345, new NumericFormatOptions
+ {
+ CompactThreshold = 1_000_000m,
+ UseGroupingBelowThreshold = true,
+ FormatProvider = CultureInfo.InvariantCulture
+ });
+
+ Assert.That(result, Is.EqualTo("12,345"));
+ }
+
+ [Test]
+ public void Format_ShouldSupportCustomSuffixRule()
+ {
+ var rule = new NumericSuffixFormatRule("custom",
+ [
+ new NumericSuffixThreshold(10m, "X"),
+ new NumericSuffixThreshold(100m, "Y")
+ ]);
+
+ var result = NumericDisplay.Format(123, new NumericFormatOptions
+ {
+ Rule = rule,
+ CompactThreshold = 10m,
+ FormatProvider = CultureInfo.InvariantCulture
+ });
+
+ Assert.That(result, Is.EqualTo("1.2Y"));
+ }
+
+ [Test]
+ public void Format_ShouldHandlePositiveInfinity()
+ {
+ var result = NumericDisplay.Format(double.PositiveInfinity, new NumericFormatOptions
+ {
+ FormatProvider = CultureInfo.InvariantCulture
+ });
+
+ Assert.That(result, Is.EqualTo("Infinity"));
+ }
+
+ [Test]
+ public void Format_ObjectOverload_ShouldDispatchToNumericFormatter()
+ {
+ var result = NumericDisplay.Format((object)1_234m);
+
+ Assert.That(result, Is.EqualTo("1.2K"));
+ }
+
+ [Test]
+ public void ToCompactString_ShouldUseNumericExtension()
+ {
+ var result = 15_320.ToCompactString();
+
+ Assert.That(result, Is.EqualTo("15.3K"));
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core/Extensions/NumericDisplayExtensions.cs b/GFramework.Core/Extensions/NumericDisplayExtensions.cs
new file mode 100644
index 0000000..d67c7c9
--- /dev/null
+++ b/GFramework.Core/Extensions/NumericDisplayExtensions.cs
@@ -0,0 +1,30 @@
+using System.Numerics;
+using GFramework.Core.Abstractions.Utility.Numeric;
+using GFramework.Core.Utility.Numeric;
+
+namespace GFramework.Core.Extensions;
+
+///
+/// 数值显示扩展方法。
+///
+public static class NumericDisplayExtensions
+{
+ ///
+ /// 按指定选项将数值格式化为展示字符串。
+ ///
+ public static string ToDisplayString(this T value, NumericFormatOptions? options = null) where T : INumber
+ {
+ return NumericDisplay.Format(value, options);
+ }
+
+ ///
+ /// 使用默认紧凑风格将数值格式化为展示字符串。
+ ///
+ public static string ToCompactString(
+ this T value,
+ int maxDecimalPlaces = 1,
+ IFormatProvider? formatProvider = null) where T : INumber
+ {
+ return NumericDisplay.FormatCompact(value, maxDecimalPlaces, formatProvider);
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core/Localization/Formatters/CompactNumberLocalizationFormatter.cs b/GFramework.Core/Localization/Formatters/CompactNumberLocalizationFormatter.cs
new file mode 100644
index 0000000..7630842
--- /dev/null
+++ b/GFramework.Core/Localization/Formatters/CompactNumberLocalizationFormatter.cs
@@ -0,0 +1,167 @@
+using GFramework.Core.Abstractions.Localization;
+using GFramework.Core.Abstractions.Utility.Numeric;
+using GFramework.Core.Utility.Numeric;
+
+namespace GFramework.Core.Localization.Formatters;
+
+///
+/// 紧凑数值格式化器。
+/// 格式: {value:compact} 或 {value:compact:maxDecimals=2,trimZeros=false}
+///
+public sealed class CompactNumberLocalizationFormatter : ILocalizationFormatter
+{
+ ///
+ /// 获取格式化器的名称
+ ///
+ public string Name => "compact";
+
+
+ ///
+ /// 尝试将指定值按照紧凑数值格式进行格式化
+ ///
+ /// 格式字符串,可包含以下选项:
+ /// maxDecimals: 最大小数位数
+ /// minDecimals: 最小小数位数
+ /// trimZeros: 是否去除尾随零
+ /// grouping: 是否在阈值以下使用分组
+ /// 要格式化的数值对象
+ /// 格式提供程序,用于区域性特定的格式设置
+ /// 格式化后的字符串结果
+ /// 如果格式化成功则返回true;如果格式字符串无效或格式化失败则返回false
+ public bool TryFormat(string format, object value, IFormatProvider? provider, out string result)
+ {
+ result = string.Empty;
+
+ if (!TryParseOptions(format, provider, out var options))
+ {
+ return false;
+ }
+
+ try
+ {
+ result = NumericDisplay.Format(value, options);
+ return true;
+ }
+ catch (ArgumentNullException)
+ {
+ return false;
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// 尝试解析格式字符串中的选项参数
+ ///
+ /// 格式字符串,包含以逗号分隔的键值对,如"maxDecimals=2,trimZeros=false"
+ /// 格式提供程序
+ /// 解析成功的选项输出
+ /// 如果所有选项都正确解析则返回true;如果有任何语法错误或无效值则返回false
+ ///
+ /// 支持的选项包括:
+ /// - maxDecimals: 最大小数位数,必须是有效整数
+ /// - minDecimals: 最小小数位数,必须是有效整数
+ /// - trimZeros: 是否去除尾随零,必须是有效布尔值
+ /// - grouping: 是否在阈值以下使用分组,必须是有效布尔值
+ /// 选项之间用逗号或分号分隔,格式为key=value
+ ///
+ private static bool TryParseOptions(string format, IFormatProvider? provider, out NumericFormatOptions options)
+ {
+ options = new NumericFormatOptions
+ {
+ FormatProvider = provider
+ };
+
+ if (string.IsNullOrWhiteSpace(format))
+ {
+ return true;
+ }
+
+ var maxDecimalPlaces = options.MaxDecimalPlaces;
+ var minDecimalPlaces = options.MinDecimalPlaces;
+ var trimTrailingZeros = options.TrimTrailingZeros;
+ var useGroupingBelowThreshold = options.UseGroupingBelowThreshold;
+
+ foreach (var segment in format.Split([',', ';'],
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
+ {
+ if (!TryParseSegment(segment, out var key, out var value))
+ {
+ return false;
+ }
+
+ if (!TryApplyOption(
+ key,
+ value,
+ ref maxDecimalPlaces,
+ ref minDecimalPlaces,
+ ref trimTrailingZeros,
+ ref useGroupingBelowThreshold))
+ {
+ return false;
+ }
+ }
+
+ options = options with
+ {
+ MaxDecimalPlaces = maxDecimalPlaces,
+ MinDecimalPlaces = minDecimalPlaces,
+ TrimTrailingZeros = trimTrailingZeros,
+ UseGroupingBelowThreshold = useGroupingBelowThreshold
+ };
+
+ return true;
+ }
+
+ ///
+ /// 尝试解析格式字符串中的单个键值对片段。
+ ///
+ /// 包含键值对的字符串片段,格式应为"key=value"
+ /// 解析得到的键名
+ /// 解析得到的值
+ /// 如果片段格式有效且成功解析则返回true;如果格式无效(如缺少分隔符、空键等)则返回false
+ private static bool TryParseSegment(string segment, out string key, out string value)
+ {
+ var separatorIndex = segment.IndexOf('=');
+ if (separatorIndex <= 0 || separatorIndex == segment.Length - 1)
+ {
+ key = string.Empty;
+ value = string.Empty;
+ return false;
+ }
+
+ key = segment[..separatorIndex].Trim();
+ value = segment[(separatorIndex + 1)..].Trim();
+ return true;
+ }
+
+ ///
+ /// 尝试将解析得到的键值对应用到相应的选项变量中。
+ ///
+ /// 选项名称
+ /// 选项值的字符串表示
+ /// 最大小数位数的引用参数
+ /// 最小小数位数的引用参数
+ /// 是否去除尾随零的引用参数
+ /// 是否在阈值以下使用分组的引用参数
+ /// 如果键名有效且值成功解析则返回true;如果键名无效或值解析失败则返回false
+ private static bool TryApplyOption(
+ string key,
+ string value,
+ ref int maxDecimalPlaces,
+ ref int minDecimalPlaces,
+ ref bool trimTrailingZeros,
+ ref bool useGroupingBelowThreshold)
+ {
+ return key switch
+ {
+ "maxDecimals" => int.TryParse(value, out maxDecimalPlaces),
+ "minDecimals" => int.TryParse(value, out minDecimalPlaces),
+ "trimZeros" => bool.TryParse(value, out trimTrailingZeros),
+ "grouping" => bool.TryParse(value, out useGroupingBelowThreshold),
+ _ => false
+ };
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core/Localization/LocalizationManager.cs b/GFramework.Core/Localization/LocalizationManager.cs
index 5ff4d88..9c38cf1 100644
--- a/GFramework.Core/Localization/LocalizationManager.cs
+++ b/GFramework.Core/Localization/LocalizationManager.cs
@@ -2,6 +2,7 @@ using System.Globalization;
using System.IO;
using System.Text.Json;
using GFramework.Core.Abstractions.Localization;
+using GFramework.Core.Localization.Formatters;
using GFramework.Core.Systems;
namespace GFramework.Core.Localization;
@@ -11,11 +12,11 @@ namespace GFramework.Core.Localization;
///
public class LocalizationManager : AbstractSystem, ILocalizationManager
{
+ private readonly List _availableLanguages;
private readonly LocalizationConfig _config;
private readonly Dictionary _formatters;
private readonly List> _languageChangeCallbacks;
private readonly Dictionary> _tables;
- private List _availableLanguages;
private CultureInfo _currentCulture;
private string _currentLanguage;
@@ -32,6 +33,7 @@ public class LocalizationManager : AbstractSystem, ILocalizationManager
_currentLanguage = _config.DefaultLanguage;
_currentCulture = GetCultureInfo(_currentLanguage);
_availableLanguages = new List();
+ RegisterBuiltInFormatters();
}
///
@@ -178,6 +180,13 @@ public class LocalizationManager : AbstractSystem, ILocalizationManager
_languageChangeCallbacks.Clear();
}
+ private void RegisterBuiltInFormatters()
+ {
+ RegisterFormatter("if", new ConditionalFormatter());
+ RegisterFormatter("plural", new PluralFormatter());
+ RegisterFormatter("compact", new CompactNumberLocalizationFormatter());
+ }
+
///
/// 扫描可用语言
///
diff --git a/GFramework.Core/Utility/Numeric/NumericDisplay.cs b/GFramework.Core/Utility/Numeric/NumericDisplay.cs
new file mode 100644
index 0000000..eb6997e
--- /dev/null
+++ b/GFramework.Core/Utility/Numeric/NumericDisplay.cs
@@ -0,0 +1,58 @@
+using System.Numerics;
+using GFramework.Core.Abstractions.Utility.Numeric;
+
+namespace GFramework.Core.Utility.Numeric;
+
+///
+/// 数值显示静态入口。
+///
+public static class NumericDisplay
+{
+ private static readonly NumericDisplayFormatter DefaultFormatter = new();
+
+ ///
+ /// 将数值格式化为展示字符串。
+ ///
+ public static string Format(T value, NumericFormatOptions? options = null) where T : INumber
+ {
+ return DefaultFormatter.Format(value, options);
+ }
+
+ ///
+ /// 将运行时数值对象格式化为展示字符串。
+ ///
+ public static string Format(object value, NumericFormatOptions? options = null)
+ {
+ return DefaultFormatter.Format(value, options);
+ }
+
+ ///
+ /// 使用默认紧凑风格格式化数值。
+ ///
+ public static string FormatCompact(
+ T value,
+ int maxDecimalPlaces = 1,
+ IFormatProvider? formatProvider = null) where T : INumber
+ {
+ return Format(value, new NumericFormatOptions
+ {
+ MaxDecimalPlaces = maxDecimalPlaces,
+ FormatProvider = formatProvider
+ });
+ }
+
+ ///
+ /// 使用默认紧凑风格格式化运行时数值对象。
+ ///
+ public static string FormatCompact(
+ object value,
+ int maxDecimalPlaces = 1,
+ IFormatProvider? formatProvider = null)
+ {
+ return Format(value, new NumericFormatOptions
+ {
+ MaxDecimalPlaces = maxDecimalPlaces,
+ FormatProvider = formatProvider
+ });
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs b/GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs
new file mode 100644
index 0000000..e9afc87
--- /dev/null
+++ b/GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs
@@ -0,0 +1,124 @@
+using System.Globalization;
+using System.Numerics;
+using GFramework.Core.Abstractions.Utility.Numeric;
+
+namespace GFramework.Core.Utility.Numeric;
+
+///
+/// 默认数值显示格式化器。
+///
+public sealed class NumericDisplayFormatter : INumericDisplayFormatter
+{
+ private readonly INumericFormatRule _defaultRule;
+
+ ///
+ /// 初始化默认数值显示格式化器。
+ ///
+ public NumericDisplayFormatter()
+ : this(NumericSuffixFormatRule.InternationalCompact)
+ {
+ }
+
+ ///
+ /// 初始化数值显示格式化器。
+ ///
+ /// 默认规则。
+ public NumericDisplayFormatter(INumericFormatRule defaultRule)
+ {
+ _defaultRule = defaultRule ?? throw new ArgumentNullException(nameof(defaultRule));
+ }
+
+ ///
+ public string Format(T value, NumericFormatOptions? options = null)
+ {
+ if (value is null)
+ {
+ throw new ArgumentNullException(nameof(value));
+ }
+
+ var resolvedOptions = NormalizeOptions(options);
+ var rule = resolvedOptions.Rule ?? _defaultRule;
+
+ if (rule.TryFormat(value, resolvedOptions, out var result))
+ {
+ return result;
+ }
+
+ return FormatFallback(value!, resolvedOptions.FormatProvider);
+ }
+
+ ///
+ /// 将运行时数值对象格式化为展示字符串。
+ ///
+ /// 待格式化的数值对象。
+ /// 格式化选项。
+ /// 格式化后的字符串。
+ public string Format(object value, NumericFormatOptions? options = null)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ return value switch
+ {
+ byte byteValue => Format(byteValue, options),
+ sbyte sbyteValue => Format(sbyteValue, options),
+ short shortValue => Format(shortValue, options),
+ ushort ushortValue => Format(ushortValue, options),
+ int intValue => Format(intValue, options),
+ uint uintValue => Format(uintValue, options),
+ long longValue => Format(longValue, options),
+ ulong ulongValue => Format(ulongValue, options),
+ nint nativeIntValue => Format(nativeIntValue, options),
+ nuint nativeUIntValue => Format(nativeUIntValue, options),
+ float floatValue => Format(floatValue, options),
+ double doubleValue => Format(doubleValue, options),
+ decimal decimalValue => Format(decimalValue, options),
+ BigInteger bigIntegerValue => Format(bigIntegerValue, options),
+ _ => FormatFallback(value, options?.FormatProvider)
+ };
+ }
+
+ internal static NumericFormatOptions NormalizeOptions(NumericFormatOptions? options)
+ {
+ var resolved = options ?? new NumericFormatOptions();
+
+ if (resolved.MaxDecimalPlaces < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(options),
+ resolved.MaxDecimalPlaces,
+ "MaxDecimalPlaces 不能小于 0。");
+ }
+
+ if (resolved.MinDecimalPlaces < 0)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(options),
+ resolved.MinDecimalPlaces,
+ "MinDecimalPlaces 不能小于 0。");
+ }
+
+ if (resolved.MinDecimalPlaces > resolved.MaxDecimalPlaces)
+ {
+ throw new ArgumentException("MinDecimalPlaces 不能大于 MaxDecimalPlaces。", nameof(options));
+ }
+
+ if (resolved.CompactThreshold <= 0m)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(options),
+ resolved.CompactThreshold,
+ "CompactThreshold 必须大于 0。");
+ }
+
+ return resolved;
+ }
+
+ private static string FormatFallback(object value, IFormatProvider? provider)
+ {
+ return value switch
+ {
+ IFormattable formattable => formattable.ToString(null, provider ?? CultureInfo.CurrentCulture),
+ _ => value.ToString() ?? string.Empty
+ };
+ }
+}
\ No newline at end of file
diff --git a/GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs b/GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs
new file mode 100644
index 0000000..3e1f706
--- /dev/null
+++ b/GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs
@@ -0,0 +1,346 @@
+using System.Globalization;
+using System.Numerics;
+using GFramework.Core.Abstractions.Utility.Numeric;
+
+namespace GFramework.Core.Utility.Numeric;
+
+///
+/// 基于后缀阈值表的数值缩写规则。
+///
+public sealed class NumericSuffixFormatRule : INumericFormatRule
+{
+ private readonly NumericSuffixThreshold[] _thresholds;
+
+ ///
+ /// 初始化后缀缩写规则。
+ ///
+ /// 规则名称。
+ /// 阈值表。
+ public NumericSuffixFormatRule(string name, IEnumerable thresholds)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(name);
+ ArgumentNullException.ThrowIfNull(thresholds);
+
+ Name = name;
+ _thresholds = thresholds.OrderBy(entry => entry.Divisor).ToArray();
+
+ if (_thresholds.Length == 0)
+ {
+ throw new ArgumentException("至少需要一个缩写阈值。", nameof(thresholds));
+ }
+
+ ValidateThresholds(_thresholds);
+ }
+
+ ///
+ /// 默认国际缩写规则。
+ ///
+ public static NumericSuffixFormatRule InternationalCompact { get; } = new(
+ "compact",
+ [
+ new NumericSuffixThreshold(1_000m, "K"),
+ new NumericSuffixThreshold(1_000_000m, "M"),
+ new NumericSuffixThreshold(1_000_000_000m, "B"),
+ new NumericSuffixThreshold(1_000_000_000_000m, "T")
+ ]);
+
+ ///
+ public string Name { get; }
+
+ ///
+ public bool TryFormat(T value, NumericFormatOptions options, out string result)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+ NumericDisplayFormatter.NormalizeOptions(options);
+
+ if (TryFormatSpecialFloatingPoint(value, options.FormatProvider, out result))
+ {
+ return true;
+ }
+
+ object? boxedValue = value;
+ if (boxedValue is null)
+ {
+ result = string.Empty;
+ return false;
+ }
+
+ return boxedValue switch
+ {
+ byte byteValue => TryFormatDecimal(byteValue, options, out result),
+ sbyte sbyteValue => TryFormatDecimal(sbyteValue, options, out result),
+ short shortValue => TryFormatDecimal(shortValue, options, out result),
+ ushort ushortValue => TryFormatDecimal(ushortValue, options, out result),
+ int intValue => TryFormatDecimal(intValue, options, out result),
+ uint uintValue => TryFormatDecimal(uintValue, options, out result),
+ long longValue => TryFormatDecimal(longValue, options, out result),
+ ulong ulongValue => TryFormatDecimal(ulongValue, options, out result),
+ nint nativeIntValue => TryFormatDecimal(nativeIntValue, options, out result),
+ nuint nativeUIntValue => TryFormatDecimal(nativeUIntValue, options, out result),
+ decimal decimalValue => TryFormatDecimal(decimalValue, options, out result),
+ float floatValue => TryFormatDouble(floatValue, options, out result),
+ double doubleValue => TryFormatDouble(doubleValue, options, out result),
+ BigInteger bigIntegerValue => TryFormatBigInteger(bigIntegerValue, options, out result),
+ _ => TryFormatConvertible(boxedValue, options, out result)
+ };
+ }
+
+ private static void ValidateThresholds(IReadOnlyList thresholds)
+ {
+ decimal? previousDivisor = null;
+
+ foreach (var threshold in thresholds)
+ {
+ if (threshold.Divisor <= 0m)
+ {
+ throw new ArgumentOutOfRangeException(nameof(thresholds), "阈值除数必须大于 0。");
+ }
+
+ if (string.IsNullOrWhiteSpace(threshold.Suffix))
+ {
+ throw new ArgumentException("阈值后缀不能为空。", nameof(thresholds));
+ }
+
+ if (previousDivisor.HasValue && threshold.Divisor <= previousDivisor.Value)
+ {
+ throw new ArgumentException("阈值除数必须严格递增。", nameof(thresholds));
+ }
+
+ previousDivisor = threshold.Divisor;
+ }
+ }
+
+ private static bool TryFormatSpecialFloatingPoint(
+ T value,
+ IFormatProvider? provider,
+ out string result)
+ {
+ object? boxedValue = value;
+ if (boxedValue is null)
+ {
+ result = string.Empty;
+ return false;
+ }
+
+ switch (boxedValue)
+ {
+ case float floatValue when float.IsNaN(floatValue) || float.IsInfinity(floatValue):
+ result = floatValue.ToString(null, provider);
+ return true;
+ case double doubleValue when double.IsNaN(doubleValue) || double.IsInfinity(doubleValue):
+ result = doubleValue.ToString(null, provider);
+ return true;
+ default:
+ result = string.Empty;
+ return false;
+ }
+ }
+
+ private bool TryFormatConvertible(object value, NumericFormatOptions options, out string result)
+ {
+ if (value is not IConvertible convertible)
+ {
+ result = string.Empty;
+ return false;
+ }
+
+ try
+ {
+ var decimalValue = convertible.ToDecimal(options.FormatProvider ?? CultureInfo.InvariantCulture);
+ return TryFormatDecimal(decimalValue, options, out result);
+ }
+ catch
+ {
+ result = string.Empty;
+ return false;
+ }
+ }
+
+ private bool TryFormatBigInteger(BigInteger value, NumericFormatOptions options, out string result)
+ {
+ try
+ {
+ return TryFormatDecimal((decimal)value, options, out result);
+ }
+ catch (OverflowException)
+ {
+ try
+ {
+ return TryFormatDouble((double)value, options, out result);
+ }
+ catch (OverflowException)
+ {
+ result = value.ToString(options.FormatProvider ?? CultureInfo.CurrentCulture);
+ return true;
+ }
+ }
+ }
+
+ private bool TryFormatDecimal(decimal value, NumericFormatOptions options, out string result)
+ {
+ var absoluteValue = Math.Abs(value);
+
+ if (absoluteValue < options.CompactThreshold)
+ {
+ result = FormatPlainDecimal(value, options);
+ return true;
+ }
+
+ var suffixIndex = FindThresholdIndex(absoluteValue);
+ if (suffixIndex < 0)
+ {
+ result = FormatPlainDecimal(value, options);
+ return true;
+ }
+
+ var scaledValue = RoundScaledDecimal(absoluteValue, suffixIndex, options, out suffixIndex);
+ result = ComposeResult(value < 0m, FormatDecimalCore(scaledValue, options, false), suffixIndex);
+ return true;
+ }
+
+ private bool TryFormatDouble(double value, NumericFormatOptions options, out string result)
+ {
+ var absoluteValue = Math.Abs(value);
+
+ if (absoluteValue < (double)options.CompactThreshold)
+ {
+ result = FormatPlainDouble(value, options);
+ return true;
+ }
+
+ var suffixIndex = FindThresholdIndex(absoluteValue);
+ if (suffixIndex < 0)
+ {
+ result = FormatPlainDouble(value, options);
+ return true;
+ }
+
+ var scaledValue = RoundScaledDouble(absoluteValue, suffixIndex, options, out suffixIndex);
+ result = ComposeResult(value < 0d, FormatDoubleCore(scaledValue, options, false), suffixIndex);
+ return true;
+ }
+
+ private string ComposeResult(bool negative, string numericPart, int suffixIndex)
+ {
+ return $"{(negative ? "-" : string.Empty)}{numericPart}{_thresholds[suffixIndex].Suffix}";
+ }
+
+ private int FindThresholdIndex(decimal absoluteValue)
+ {
+ for (var i = _thresholds.Length - 1; i >= 0; i--)
+ {
+ if (absoluteValue >= _thresholds[i].Divisor)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private int FindThresholdIndex(double absoluteValue)
+ {
+ for (var i = _thresholds.Length - 1; i >= 0; i--)
+ {
+ if (absoluteValue >= (double)_thresholds[i].Divisor)
+ {
+ return i;
+ }
+ }
+
+ return -1;
+ }
+
+ private decimal RoundScaledDecimal(decimal absoluteValue, int suffixIndex, NumericFormatOptions options,
+ out int resolvedIndex)
+ {
+ resolvedIndex = suffixIndex;
+ var roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options);
+
+ while (resolvedIndex < _thresholds.Length - 1)
+ {
+ var promoteThreshold = _thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor;
+ if (roundedValue < promoteThreshold)
+ {
+ break;
+ }
+
+ resolvedIndex++;
+ roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options);
+ }
+
+ return roundedValue;
+ }
+
+ private double RoundScaledDouble(double absoluteValue, int suffixIndex, NumericFormatOptions options,
+ out int resolvedIndex)
+ {
+ resolvedIndex = suffixIndex;
+ var roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options);
+
+ while (resolvedIndex < _thresholds.Length - 1)
+ {
+ var promoteThreshold =
+ (double)(_thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor);
+ if (roundedValue < promoteThreshold)
+ {
+ break;
+ }
+
+ resolvedIndex++;
+ roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options);
+ }
+
+ return roundedValue;
+ }
+
+ private static decimal RoundDecimal(decimal value, NumericFormatOptions options)
+ {
+ return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding);
+ }
+
+ private static double RoundDouble(double value, NumericFormatOptions options)
+ {
+ return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding);
+ }
+
+ private static string FormatPlainDecimal(decimal value, NumericFormatOptions options)
+ {
+ return FormatDecimalCore(RoundDecimal(value, options), options, options.UseGroupingBelowThreshold);
+ }
+
+ private static string FormatPlainDouble(double value, NumericFormatOptions options)
+ {
+ return FormatDoubleCore(RoundDouble(value, options), options, options.UseGroupingBelowThreshold);
+ }
+
+ private static string FormatDecimalCore(decimal value, NumericFormatOptions options, bool useGrouping)
+ {
+ return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider);
+ }
+
+ private static string FormatDoubleCore(double value, NumericFormatOptions options, bool useGrouping)
+ {
+ return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider);
+ }
+
+ private static string BuildFormatString(NumericFormatOptions options, bool useGrouping)
+ {
+ var integerPart = useGrouping ? "#,0" : "0";
+
+ if (options.MaxDecimalPlaces == 0)
+ {
+ return integerPart;
+ }
+
+ if (!options.TrimTrailingZeros)
+ {
+ var fixedDigits = Math.Max(options.MaxDecimalPlaces, options.MinDecimalPlaces);
+ return $"{integerPart}.{new string('0', fixedDigits)}";
+ }
+
+ var requiredDigits = new string('0', options.MinDecimalPlaces);
+ var optionalDigits = new string('#', options.MaxDecimalPlaces - options.MinDecimalPlaces);
+ return $"{integerPart}.{requiredDigits}{optionalDigits}";
+ }
+}
\ No newline at end of file