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..e37c1be 100644
--- a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs
+++ b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs
@@ -49,7 +49,11 @@ 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.unknownCompact": "Gold: {gold:compact:maxDecimalss=2}",
+ "status.invalidCompact": "Gold: {gold:compact:maxDecimals=abc}"
}
""");
@@ -57,7 +61,11 @@ 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.unknownCompact": "金币: {gold:compact:maxDecimalss=2}",
+ "status.invalidCompact": "金币: {gold:compact:maxDecimals=abc}"
}
""");
}
@@ -108,6 +116,46 @@ 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_WithUnknownCompactFormatterArgs_ShouldIgnoreUnknownOptions()
+ {
+ var gold = _manager!.GetString("common", "status.unknownCompact")
+ .WithVariable("gold", 1_250)
+ .Format();
+
+ Assert.That(gold, Is.EqualTo("Gold: 1.3K"));
+ }
+
+ [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..b37d892
--- /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),
+ _ => true
+ };
+ }
+}
\ 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..372d30b
--- /dev/null
+++ b/GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs
@@ -0,0 +1,140 @@
+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 = ResolveRule(resolvedOptions);
+
+ 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 INumericFormatRule ResolveRule(NumericFormatOptions options)
+ {
+ ArgumentNullException.ThrowIfNull(options);
+
+ if (options.Rule is not null)
+ {
+ return options.Rule;
+ }
+
+ return options.Style switch
+ {
+ NumericDisplayStyle.Compact => _defaultRule,
+ _ => throw new ArgumentOutOfRangeException(nameof(options), options.Style, "不支持的数值显示风格。")
+ };
+ }
+
+ 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..60a90f3
--- /dev/null
+++ b/GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs
@@ -0,0 +1,353 @@
+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);
+ }
+
+ ///
+ /// 默认国际缩写规则,使用标准的K、M、B、T后缀表示千、百万、十亿、万亿。
+ ///
+ 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; }
+
+ ///
+ /// 尝试将指定的数值按照当前规则进行格式化。
+ ///
+ /// 数值的类型
+ /// 要格式化的数值
+ /// 格式化选项,包含小数位数、舍入模式等设置
+ /// 格式化后的字符串结果
+ /// 如果格式化成功则返回true;如果输入无效或格式化失败则返回false
+ 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)
+ {
+ var doubleValue = (double)value;
+ if (TryFormatSpecialFloatingPoint(doubleValue, options.FormatProvider, out result))
+ {
+ return true;
+ }
+
+ return TryFormatDouble(doubleValue, options, out result);
+ }
+ }
+
+ 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
diff --git a/docs/zh-CN/core/utility.md b/docs/zh-CN/core/utility.md
index 60a9f1d..37f199e 100644
--- a/docs/zh-CN/core/utility.md
+++ b/docs/zh-CN/core/utility.md
@@ -365,6 +365,42 @@ public class EncryptionUtility : IUtility
}
```
+### 内置数值显示工具
+
+对于 UI 中常见的数值缩写显示,优先使用 `GFramework.Core` 提供的数值显示工具,而不是在业务层重复拼接字符串。
+
+```csharp
+using System.Globalization;
+using GFramework.Core.Abstractions.Utility.Numeric;
+using GFramework.Core.Extensions;
+using GFramework.Core.Utility.Numeric;
+
+var gold = NumericDisplay.FormatCompact(1250); // "1.3K"
+var damage = 15320.ToCompactString(); // "15.3K"
+
+var exact = NumericDisplay.Format(1234.56m, new NumericFormatOptions
+{
+ MaxDecimalPlaces = 2,
+ FormatProvider = CultureInfo.InvariantCulture
+}); // "1.23K"
+
+var grouped = NumericDisplay.Format(12345, new NumericFormatOptions
+{
+ CompactThreshold = 1000000m,
+ UseGroupingBelowThreshold = true,
+ FormatProvider = CultureInfo.InvariantCulture
+}); // "12,345"
+```
+
+如果你在本地化文本中展示数值,也可以直接使用内置 formatter:
+
+```json
+{
+ "status.gold": "Gold: {gold:compact}",
+ "status.damage": "Damage: {damage:compact:maxDecimals=2}"
+}
+```
+
### 5. 对象池工具
```csharp
@@ -610,4 +646,4 @@ public class CollectionUtility : IUtility
- [`command`](./command.md) - Command 中可以使用 Utility
- [`architecture`](./architecture.md) - 在架构中注册 Utility
- [`ioc`](./ioc.md) - Utility 通过 IoC 容器管理
-- [`extensions`](./extensions.md) - 提供 GetUtility 扩展方法
\ No newline at end of file
+- [`extensions`](./extensions.md) - 提供 GetUtility 扩展方法