461 lines
17 KiB
C#
461 lines
17 KiB
C#
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Globalization;
|
|
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using Newtonsoft.Json;
|
|
using XP.Common.Localization.Enums;
|
|
using XP.Common.Localization.Interfaces;
|
|
using XP.Common.Logging.Interfaces;
|
|
using XP.ReportEngine.Interfaces;
|
|
using XP.ReportEngine.Models;
|
|
|
|
namespace XP.ReportEngine.Services
|
|
{
|
|
/// <summary>
|
|
/// 表达式数据绑定器实现 | Expression data binder implementation
|
|
/// 支持 ${} 语法的数据绑定、格式化函数和本地化键解析
|
|
/// Supports ${} syntax data binding, format functions and localization key resolution
|
|
/// </summary>
|
|
public class ExpressionDataBinder : IDataBinder
|
|
{
|
|
private readonly ILoggerService _logger;
|
|
private readonly ILocalizationService _localizationService;
|
|
|
|
private static readonly Regex ExpressionPattern = new(@"\$\{([^}]+)\}", RegexOptions.Compiled);
|
|
private static readonly Regex LocalizationPattern = new(@"^loc:(.+)$", RegexOptions.Compiled);
|
|
private static readonly Regex FunctionPattern = new(@"^(\w+)\((.+)\)$", RegexOptions.Compiled);
|
|
private static readonly Regex IndexPattern = new(@"^([^\[]+)\[(\d+)\]$", RegexOptions.Compiled);
|
|
|
|
public ExpressionDataBinder(ILoggerService logger, ILocalizationService localizationService)
|
|
{
|
|
_logger = logger?.ForModule<ExpressionDataBinder>() ?? throw new ArgumentNullException(nameof(logger));
|
|
_localizationService = localizationService ?? throw new ArgumentNullException(nameof(localizationService));
|
|
}
|
|
|
|
public ReportTemplate Bind(ReportTemplate template, ReportContext context)
|
|
{
|
|
if (template == null) throw new ArgumentNullException(nameof(template));
|
|
if (context == null) throw new ArgumentNullException(nameof(context));
|
|
|
|
_logger.Info("开始数据绑定 | Starting data binding");
|
|
var clonedTemplate = DeepClone(template);
|
|
|
|
if (clonedTemplate.Pages != null)
|
|
{
|
|
foreach (var page in clonedTemplate.Pages)
|
|
{
|
|
if (page.Elements == null) continue;
|
|
foreach (var element in page.Elements)
|
|
{
|
|
BindElement(element, context);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 绑定页眉页脚中的表达式 | Bind expressions in header/footer
|
|
BindHeaderFooter(clonedTemplate, context);
|
|
|
|
_logger.Info("数据绑定完成 | Data binding completed");
|
|
return clonedTemplate;
|
|
}
|
|
|
|
|
|
private void BindElement(TemplateElement element, ReportContext context)
|
|
{
|
|
// 文本内容绑定 | Text content binding
|
|
if (!string.IsNullOrEmpty(element.Content))
|
|
{
|
|
element.Content = ResolveAllExpressions(element.Content, context);
|
|
}
|
|
|
|
// 图像元素绑定:通过 DataKey 从 ReportContext.Images 获取 ImageData
|
|
// Image element binding: get ImageData from ReportContext.Images via DataKey
|
|
if (string.Equals(element.Type, "image", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.IsNullOrEmpty(element.DataKey)
|
|
&& context.Images != null
|
|
&& context.Images.TryGetValue(element.DataKey, out var imageData))
|
|
{
|
|
element.ImageData = imageData;
|
|
}
|
|
|
|
// 表格数据绑定:通过 DataKey 从 ReportContext.Properties 获取表格行数据
|
|
// Table data binding: get table row data from ReportContext.Properties via DataKey
|
|
if (string.Equals(element.Type, "table", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.IsNullOrEmpty(element.DataKey)
|
|
&& context.Properties != null
|
|
&& context.Properties.TryGetValue(element.DataKey, out var tableValue)
|
|
&& tableValue is List<Dictionary<string, object>> tableRows)
|
|
{
|
|
element.TableData = tableRows;
|
|
}
|
|
|
|
// 表格列头绑定 | Table column header binding
|
|
if (element.Columns != null)
|
|
{
|
|
foreach (var column in element.Columns)
|
|
{
|
|
if (!string.IsNullOrEmpty(column.Header))
|
|
{
|
|
column.Header = ResolveAllExpressions(column.Header, context);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Row 子元素递归绑定 | Recursively bind row child elements
|
|
if (string.Equals(element.Type, "row", StringComparison.OrdinalIgnoreCase)
|
|
&& element.Children != null)
|
|
{
|
|
foreach (var child in element.Children)
|
|
{
|
|
BindElement(child, context);
|
|
// Column 子元素递归绑定 | Recursively bind column child elements
|
|
if (string.Equals(child.Type, "column", StringComparison.OrdinalIgnoreCase)
|
|
&& child.Children != null)
|
|
{
|
|
foreach (var subChild in child.Children)
|
|
{
|
|
BindElement(subChild, context);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 绑定页眉页脚中的 ${} 表达式 | Bind ${} expressions in header/footer
|
|
/// </summary>
|
|
private void BindHeaderFooter(ReportTemplate template, ReportContext context)
|
|
{
|
|
if (template?.Document == null) return;
|
|
|
|
// 绑定页眉文本 | Bind header text
|
|
if (template.Document.Header != null && template.Document.Header.Enabled)
|
|
{
|
|
BindStringList(template.Document.Header.Left, context);
|
|
BindStringList(template.Document.Header.Right, context);
|
|
}
|
|
|
|
// 绑定页脚文本 | Bind footer text
|
|
if (template.Document.Footer != null && template.Document.Footer.Enabled)
|
|
{
|
|
BindStringList(template.Document.Footer.Left, context);
|
|
BindStringList(template.Document.Footer.Right, context);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// 绑定字符串列表中的表达式 | Bind expressions in string list
|
|
/// </summary>
|
|
private void BindStringList(List<string> lines, ReportContext context)
|
|
{
|
|
if (lines == null) return;
|
|
for (int i = 0; i < lines.Count; i++)
|
|
{
|
|
if (!string.IsNullOrEmpty(lines[i]))
|
|
{
|
|
lines[i] = ResolveAllExpressions(lines[i], context);
|
|
}
|
|
}
|
|
}
|
|
|
|
private string ResolveAllExpressions(string input, ReportContext context)
|
|
{
|
|
return ExpressionPattern.Replace(input, match =>
|
|
{
|
|
var expression = match.Groups[1].Value.Trim();
|
|
return ResolveExpression(expression, context);
|
|
});
|
|
}
|
|
|
|
private string ResolveExpression(string expression, ReportContext context)
|
|
{
|
|
// 1. 本地化键 loc:ResourceKey | Localization key
|
|
var locMatch = LocalizationPattern.Match(expression);
|
|
if (locMatch.Success)
|
|
{
|
|
var resourceKey = locMatch.Groups[1].Value.Trim();
|
|
return ResolveLocalizationKey(resourceKey);
|
|
}
|
|
|
|
// 2. 格式化函数 functionName(params) | Format function
|
|
var funcMatch = FunctionPattern.Match(expression);
|
|
if (funcMatch.Success)
|
|
{
|
|
var functionName = funcMatch.Groups[1].Value;
|
|
var paramExpression = funcMatch.Groups[2].Value.Trim();
|
|
return ResolveFormatFunction(functionName, paramExpression, context);
|
|
}
|
|
|
|
// 3. 属性路径 | Property path
|
|
return ResolvePropertyPath(expression, context);
|
|
}
|
|
|
|
private string ResolveLocalizationKey(string resourceKey)
|
|
{
|
|
try
|
|
{
|
|
var value = _localizationService.GetString(resourceKey);
|
|
if (value == null)
|
|
{
|
|
_logger.Warn("本地化键未找到: {Key} | Localization key not found: {Key}", resourceKey);
|
|
return string.Empty;
|
|
}
|
|
return value;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.Warn("解析本地化键失败: {Key}, 错误: {Message} | Failed to resolve localization key: {Key}, error: {Message}", resourceKey, ex.Message);
|
|
return string.Empty;
|
|
}
|
|
}
|
|
|
|
|
|
private string ResolveFormatFunction(string functionName, string paramExpression, ReportContext context)
|
|
{
|
|
switch (functionName.ToLowerInvariant())
|
|
{
|
|
case "formatdate":
|
|
{
|
|
var value = ResolvePropertyValue(paramExpression, context);
|
|
return FormatDate(value);
|
|
}
|
|
case "formatnumber":
|
|
{
|
|
var parts = paramExpression.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
|
|
var value = ResolvePropertyValue(parts[0].Trim(), context);
|
|
var decimals = parts.Length > 1 && int.TryParse(parts[1].Trim(), out var d) ? d : 2;
|
|
return FormatNumber(value, decimals);
|
|
}
|
|
case "formatpercent":
|
|
{
|
|
var value = ResolvePropertyValue(paramExpression, context);
|
|
return FormatPercent(value);
|
|
}
|
|
default:
|
|
{
|
|
_logger.Warn("未知的格式化函数: {FunctionName} | Unknown format function: {FunctionName}", functionName);
|
|
return string.Empty;
|
|
}
|
|
}
|
|
}
|
|
|
|
private string ResolvePropertyPath(string path, ReportContext context)
|
|
{
|
|
var value = ResolvePropertyValue(path, context);
|
|
if (value == null)
|
|
{
|
|
_logger.Warn("绑定属性未找到: {Path},替换为空字符串 | Binding property not found: {Path}, replacing with empty string", path);
|
|
return string.Empty;
|
|
}
|
|
return ConvertToString(value);
|
|
}
|
|
|
|
private object ResolvePropertyValue(string path, ReportContext context)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(path)) return null;
|
|
path = path.Trim();
|
|
|
|
// 优先从 Properties 字典查找 | First look up in Properties dictionary
|
|
if (context.Properties != null && context.Properties.TryGetValue(path, out var directValue))
|
|
{
|
|
return directValue;
|
|
}
|
|
|
|
// 尝试从 context 对象解析嵌套路径 | Try nested path from context object
|
|
var resolved = ResolveNestedPath(path, context);
|
|
return resolved;
|
|
}
|
|
|
|
|
|
private object ResolveNestedPath(string path, object root)
|
|
{
|
|
if (root == null || string.IsNullOrWhiteSpace(path)) return null;
|
|
|
|
var segments = path.Split(new[] { '.' }, StringSplitOptions.RemoveEmptyEntries);
|
|
var current = root;
|
|
|
|
foreach (var segment in segments)
|
|
{
|
|
if (current == null) return null;
|
|
|
|
var indexMatch = IndexPattern.Match(segment);
|
|
if (indexMatch.Success)
|
|
{
|
|
var propertyName = indexMatch.Groups[1].Value;
|
|
var index = int.Parse(indexMatch.Groups[2].Value);
|
|
current = GetPropertyValue(current, propertyName);
|
|
if (current == null) return null;
|
|
current = GetIndexedValue(current, index);
|
|
}
|
|
else
|
|
{
|
|
current = GetPropertyValue(current, segment);
|
|
}
|
|
}
|
|
|
|
return current;
|
|
}
|
|
|
|
private object GetPropertyValue(object obj, string propertyName)
|
|
{
|
|
if (obj == null || string.IsNullOrWhiteSpace(propertyName)) return null;
|
|
|
|
// 字典访问 | Dictionary access
|
|
if (obj is IDictionary<string, object> dict)
|
|
{
|
|
if (dict.TryGetValue(propertyName, out var dictValue))
|
|
return dictValue;
|
|
foreach (var kvp in dict)
|
|
{
|
|
if (string.Equals(kvp.Key, propertyName, StringComparison.OrdinalIgnoreCase))
|
|
return kvp.Value;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// 反射获取属性 | Reflection property access
|
|
var type = obj.GetType();
|
|
var propInfo = type.GetProperty(propertyName,
|
|
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
|
|
if (propInfo != null)
|
|
{
|
|
try { return propInfo.GetValue(obj); }
|
|
catch { return null; }
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private object GetIndexedValue(object obj, int index)
|
|
{
|
|
if (obj == null || index < 0) return null;
|
|
|
|
if (obj is IList list)
|
|
{
|
|
return index < list.Count ? list[index] : null;
|
|
}
|
|
|
|
if (obj is IEnumerable enumerable)
|
|
{
|
|
var i = 0;
|
|
foreach (var item in enumerable)
|
|
{
|
|
if (i == index) return item;
|
|
i++;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
|
|
private string FormatDate(object value)
|
|
{
|
|
if (value == null) return string.Empty;
|
|
|
|
DateTime dateTime;
|
|
if (value is DateTime dt)
|
|
{
|
|
dateTime = dt;
|
|
}
|
|
else if (DateTime.TryParse(value.ToString(), out var parsed))
|
|
{
|
|
dateTime = parsed;
|
|
}
|
|
else
|
|
{
|
|
_logger.Warn("无法将值转换为日期: {Value} | Cannot convert value to date: {Value}", value);
|
|
return value.ToString();
|
|
}
|
|
|
|
var format = _localizationService.CurrentLanguage switch
|
|
{
|
|
SupportedLanguage.ZhCN => "yyyy年MM月dd日 HH:mm:ss",
|
|
SupportedLanguage.ZhTW => "yyyy年MM月dd日 HH:mm:ss",
|
|
SupportedLanguage.EnUS => "MM/dd/yyyy HH:mm:ss",
|
|
_ => "yyyy-MM-dd HH:mm:ss"
|
|
};
|
|
|
|
return dateTime.ToString(format);
|
|
}
|
|
|
|
private string FormatNumber(object value, int decimals)
|
|
{
|
|
if (value == null) return string.Empty;
|
|
|
|
if (!TryConvertToDouble(value, out var number))
|
|
{
|
|
_logger.Warn("无法将值转换为数字: {Value} | Cannot convert value to number: {Value}", value);
|
|
return value.ToString();
|
|
}
|
|
|
|
var culture = GetCultureInfo();
|
|
return number.ToString($"N{decimals}", culture);
|
|
}
|
|
|
|
private string FormatPercent(object value)
|
|
{
|
|
if (value == null) return string.Empty;
|
|
|
|
if (!TryConvertToDouble(value, out var number))
|
|
{
|
|
_logger.Warn("无法将值转换为百分比: {Value} | Cannot convert value to percentage: {Value}", value);
|
|
return value.ToString();
|
|
}
|
|
|
|
// 值在 0-1 范围内视为小数百分比 | Values in 0-1 range treated as decimal percentage
|
|
if (number >= 0 && number <= 1)
|
|
{
|
|
number *= 100;
|
|
}
|
|
|
|
var culture = GetCultureInfo();
|
|
return number.ToString("F2", culture) + "%";
|
|
}
|
|
|
|
|
|
private bool TryConvertToDouble(object value, out double result)
|
|
{
|
|
result = 0;
|
|
if (value == null) return false;
|
|
|
|
switch (value)
|
|
{
|
|
case double d: result = d; return true;
|
|
case float f: result = f; return true;
|
|
case int i: result = i; return true;
|
|
case long l: result = l; return true;
|
|
case decimal dec: result = (double)dec; return true;
|
|
default:
|
|
return double.TryParse(value.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out result);
|
|
}
|
|
}
|
|
|
|
private CultureInfo GetCultureInfo()
|
|
{
|
|
return _localizationService.CurrentLanguage switch
|
|
{
|
|
SupportedLanguage.ZhCN => new CultureInfo("zh-CN"),
|
|
SupportedLanguage.ZhTW => new CultureInfo("zh-TW"),
|
|
SupportedLanguage.EnUS => new CultureInfo("en-US"),
|
|
_ => CultureInfo.InvariantCulture
|
|
};
|
|
}
|
|
|
|
private string ConvertToString(object value)
|
|
{
|
|
if (value == null) return string.Empty;
|
|
if (value is DateTime dt) return FormatDate(dt);
|
|
return value.ToString();
|
|
}
|
|
|
|
private ReportTemplate DeepClone(ReportTemplate template)
|
|
{
|
|
var json = JsonConvert.SerializeObject(template);
|
|
return JsonConvert.DeserializeObject<ReportTemplate>(json);
|
|
}
|
|
}
|
|
}
|