增强打印预览功能,新增离线打印功能,新增缩放控制按钮以提升用户体验。优化打印数据准备逻辑,支持实时预览缩放,确保打印效果的一致性。同时,重构相关视图和服务以增强系统的可维护性和扩展性。
This commit is contained in:
@@ -23,7 +23,7 @@ public static class NativePrintRenderService
|
|||||||
/// 将模板 JSON + 数据对象渲染为完整的可打印 HTML 页面(自包含,无外部依赖)。
|
/// 将模板 JSON + 数据对象渲染为完整的可打印 HTML 页面(自包含,无外部依赖)。
|
||||||
/// 屏幕样式与后端前端预览保持一致:深灰底(#525659)+ 白纸居中 + 页间分割线。
|
/// 屏幕样式与后端前端预览保持一致:深灰底(#525659)+ 白纸居中 + 页间分割线。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static string RenderToHtml(string templateJson, JsonObject data)
|
public static string RenderToHtml(string templateJson, JsonObject data, bool enableScreenAutoFit = true)
|
||||||
{
|
{
|
||||||
var schema = JsonNode.Parse(templateJson) ?? throw new ArgumentException("无效模板 JSON");
|
var schema = JsonNode.Parse(templateJson) ?? throw new ArgumentException("无效模板 JSON");
|
||||||
var page = schema["page"] ?? throw new ArgumentException("模板缺少 page 配置");
|
var page = schema["page"] ?? throw new ArgumentException("模板缺少 page 配置");
|
||||||
@@ -86,29 +86,32 @@ public static class NativePrintRenderService
|
|||||||
sb.Append(" .qhmes-native-screen-page-sep { display: none !important; }\n");
|
sb.Append(" .qhmes-native-screen-page-sep { display: none !important; }\n");
|
||||||
sb.Append(" }\n");
|
sb.Append(" }\n");
|
||||||
sb.Append("</style>\n");
|
sb.Append("</style>\n");
|
||||||
sb.Append("<script>\n");
|
if (enableScreenAutoFit)
|
||||||
sb.Append("(function(){\n");
|
{
|
||||||
sb.Append(" function fitPage(){\n");
|
sb.Append("<script>\n");
|
||||||
sb.Append(" var p=document.querySelector('.qhmes-native-print-root');\n");
|
sb.Append("(function(){\n");
|
||||||
sb.Append(" if(!p)return;\n");
|
sb.Append(" function fitPage(){\n");
|
||||||
sb.Append(" var vw=window.innerWidth||document.documentElement.clientWidth||1;\n");
|
sb.Append(" var p=document.querySelector('.qhmes-native-print-root');\n");
|
||||||
sb.Append(" var vh=window.innerHeight||document.documentElement.clientHeight||1;\n");
|
sb.Append(" if(!p)return;\n");
|
||||||
sb.Append(" var pw=p.offsetWidth||1;\n");
|
sb.Append(" var vw=window.innerWidth||document.documentElement.clientWidth||1;\n");
|
||||||
sb.Append(" var ph=p.offsetHeight||1;\n");
|
sb.Append(" var vh=window.innerHeight||document.documentElement.clientHeight||1;\n");
|
||||||
sb.Append(" var maxW=Math.max(1,vw-40);\n");
|
sb.Append(" var pw=p.offsetWidth||1;\n");
|
||||||
sb.Append(" var maxH=Math.max(1,vh-56);\n");
|
sb.Append(" var ph=p.offsetHeight||1;\n");
|
||||||
sb.Append(" var s=Math.min(maxW/pw,maxH/ph,1);\n");
|
sb.Append(" var maxW=Math.max(1,vw-40);\n");
|
||||||
sb.Append(" p.style.transform='scale('+s+')';\n");
|
sb.Append(" var maxH=Math.max(1,vh-56);\n");
|
||||||
sb.Append(" p.style.transformOrigin='top center';\n");
|
sb.Append(" var s=Math.min(maxW/pw,maxH/ph,1);\n");
|
||||||
sb.Append(" p.style.marginBottom=((1-s)*ph)+'px';\n");
|
sb.Append(" p.style.transform='scale('+s+')';\n");
|
||||||
sb.Append(" }\n");
|
sb.Append(" p.style.transformOrigin='top center';\n");
|
||||||
sb.Append(" window.addEventListener('load',fitPage);\n");
|
sb.Append(" p.style.marginBottom=((1-s)*ph)+'px';\n");
|
||||||
sb.Append(" window.addEventListener('resize',fitPage);\n");
|
sb.Append(" }\n");
|
||||||
sb.Append(" setTimeout(fitPage,0);\n");
|
sb.Append(" window.addEventListener('load',fitPage);\n");
|
||||||
sb.Append(" setTimeout(fitPage,150);\n");
|
sb.Append(" window.addEventListener('resize',fitPage);\n");
|
||||||
sb.Append(" setTimeout(fitPage,400);\n");
|
sb.Append(" setTimeout(fitPage,0);\n");
|
||||||
sb.Append("})();\n");
|
sb.Append(" setTimeout(fitPage,150);\n");
|
||||||
sb.Append("</script>\n");
|
sb.Append(" setTimeout(fitPage,400);\n");
|
||||||
|
sb.Append("})();\n");
|
||||||
|
sb.Append("</script>\n");
|
||||||
|
}
|
||||||
sb.Append("</head>\n<body>\n");
|
sb.Append("</head>\n<body>\n");
|
||||||
sb.Append($"<div class=\"qhmes-native-print-root\" style=\"width:{wStr}mm;min-height:{thStr}mm;height:auto;overflow:visible;box-sizing:border-box;\">\n");
|
sb.Append($"<div class=\"qhmes-native-print-root\" style=\"width:{wStr}mm;min-height:{thStr}mm;height:auto;overflow:visible;box-sizing:border-box;\">\n");
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Prism.Events;
|
using Prism.Events;
|
||||||
@@ -20,6 +21,8 @@ public class RawMaterialCardService : IRawMaterialCardService, ISingletonDepende
|
|||||||
private readonly INetworkMonitor _networkMonitor;
|
private readonly INetworkMonitor _networkMonitor;
|
||||||
private readonly IEventAggregator _eventAggregator;
|
private readonly IEventAggregator _eventAggregator;
|
||||||
private readonly ILoggerService _logger;
|
private readonly ILoggerService _logger;
|
||||||
|
private readonly IPrintBizTemplateBindService _printBizTemplateBindService;
|
||||||
|
private readonly IPrintTemplateService _printTemplateService;
|
||||||
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||||
private readonly object _cacheLock = new();
|
private readonly object _cacheLock = new();
|
||||||
private readonly string _pendingOpsFilePath;
|
private readonly string _pendingOpsFilePath;
|
||||||
@@ -39,13 +42,17 @@ public class RawMaterialCardService : IRawMaterialCardService, ISingletonDepende
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
INetworkMonitor networkMonitor,
|
INetworkMonitor networkMonitor,
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
ILoggerService logger)
|
ILoggerService logger,
|
||||||
|
IPrintBizTemplateBindService printBizTemplateBindService,
|
||||||
|
IPrintTemplateService printTemplateService)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_networkMonitor = networkMonitor;
|
_networkMonitor = networkMonitor;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_printBizTemplateBindService = printBizTemplateBindService;
|
||||||
|
_printTemplateService = printTemplateService;
|
||||||
|
|
||||||
var appDataDir = Path.Combine(
|
var appDataDir = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
@@ -66,6 +73,7 @@ public class RawMaterialCardService : IRawMaterialCardService, ISingletonDepende
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const int MaxPendingRetries = 5;
|
private const int MaxPendingRetries = 5;
|
||||||
|
private const string RawMaterialCardTemplateCode = "MES_RAW_MATERIAL_CARD";
|
||||||
private string BaseUrl => (_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
|
private string BaseUrl => (_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
|
||||||
private int DefaultTenantId => (int?)_configuration.GetValue<long?>("JeecgIntegration:DefaultTenantId") ?? 1002;
|
private int DefaultTenantId => (int?)_configuration.GetValue<long?>("JeecgIntegration:DefaultTenantId") ?? 1002;
|
||||||
|
|
||||||
@@ -319,35 +327,263 @@ public class RawMaterialCardService : IRawMaterialCardService, ISingletonDepende
|
|||||||
|
|
||||||
public async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default)
|
public async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!_networkMonitor.IsOnline)
|
if (_networkMonitor.IsOnline)
|
||||||
return (string.Empty, "{}", "当前离线,无法获取打印数据");
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/anon/prepareNativePrint?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||||||
|
using var client = CreateClient();
|
||||||
|
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (!root.TryGetProperty("code", out var codeEl) || codeEl.GetInt32() != 200)
|
||||||
|
{
|
||||||
|
var msg = root.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "未知错误";
|
||||||
|
return (string.Empty, "{}", msg ?? "服务端返回错误");
|
||||||
|
}
|
||||||
|
var result = root.GetProperty("result");
|
||||||
|
var templateJson = result.TryGetProperty("templateJson", out var tjEl) ? tjEl.GetString() : null;
|
||||||
|
var printDataJson = result.TryGetProperty("printData", out var pdEl)
|
||||||
|
? pdEl.GetRawText()
|
||||||
|
: "{}";
|
||||||
|
if (string.IsNullOrWhiteSpace(templateJson))
|
||||||
|
return (string.Empty, "{}", "服务端未返回模板 JSON,请先在「业务打印绑定」中配置原材料卡片");
|
||||||
|
return (templateJson!, printDataJson, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning($"[原材料卡片] 远端准备打印数据失败,回退本地模板渲染 id={id}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await PrepareLocalNativePrintAsync(id, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareLocalNativePrintAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var card = GetCardSnapshotById(id);
|
||||||
|
if (card == null)
|
||||||
|
{
|
||||||
|
return (string.Empty, "{}", "本地未找到原材料卡片,无法离线打印");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bindList = _printBizTemplateBindService.GetCached();
|
||||||
|
if (bindList.Count == 0)
|
||||||
|
{
|
||||||
|
try { bindList = await _printBizTemplateBindService.ListAsync(ct).ConfigureAwait(false); } catch { }
|
||||||
|
}
|
||||||
|
var bind = bindList.FirstOrDefault(x => string.Equals(x.TemplateCode, RawMaterialCardTemplateCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? bindList.FirstOrDefault(x => (x.BizName ?? string.Empty).Contains("原材料卡片", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (bind == null)
|
||||||
|
{
|
||||||
|
return (string.Empty, "{}", "未找到本地业务打印绑定,请先在线同步「原材料卡片」模板配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
var templates = _printTemplateService.GetCached();
|
||||||
|
if (templates.Count == 0)
|
||||||
|
{
|
||||||
|
try { templates = await _printTemplateService.ListAsync(ct).ConfigureAwait(false); } catch { }
|
||||||
|
}
|
||||||
|
var templateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialCardTemplateCode : bind.TemplateCode!;
|
||||||
|
var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, templateCode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson))
|
||||||
|
{
|
||||||
|
return (string.Empty, "{}", "本地未找到打印模板,请先在线同步模板后再离线打印");
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!;
|
||||||
|
var printData = BuildPrintDataFromMapping(card, mappingJson, tpl.TemplateJson!);
|
||||||
|
return (tpl.TemplateJson!, printData.ToJsonString(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MesXslRawMaterialCard? GetCardSnapshotById(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
var snapshot = ApplyPendingOpsSnapshotUnsafe(_localCache.Select(Clone).ToList());
|
||||||
|
return snapshot.FirstOrDefault(c => string.Equals(c.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found
|
||||||
|
? Clone(found)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject BuildPrintDataFromMapping<T>(T source, string mappingJson, string templateJson)
|
||||||
|
{
|
||||||
|
JsonObject printData = new();
|
||||||
|
JsonNode? bizRoot = JsonSerializer.SerializeToNode(source, _jsonOpts);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialCard/anon/prepareNativePrint?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
var mappingNode = JsonNode.Parse(mappingJson) as JsonArray;
|
||||||
using var client = CreateClient();
|
if (mappingNode != null)
|
||||||
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
|
||||||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
if (!root.TryGetProperty("code", out var codeEl) || codeEl.GetInt32() != 200)
|
|
||||||
{
|
{
|
||||||
var msg = root.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "未知错误";
|
foreach (var rule in mappingNode)
|
||||||
return (string.Empty, "{}", msg ?? "服务端返回错误");
|
{
|
||||||
|
if (rule is not JsonObject obj) continue;
|
||||||
|
var templateField = obj["templateField"]?.GetValue<string>()?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(templateField)) continue;
|
||||||
|
var bizField = obj["bizField"]?.GetValue<string>()?.Trim();
|
||||||
|
JsonNode? value = string.IsNullOrWhiteSpace(bizField)
|
||||||
|
? JsonValue.Create(string.Empty)
|
||||||
|
: ResolvePath(bizRoot, bizField!);
|
||||||
|
SetPath(printData, templateField!, NormalizePrintNodeValue(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var result = root.GetProperty("result");
|
|
||||||
var templateJson = result.TryGetProperty("templateJson", out var tjEl) ? tjEl.GetString() : null;
|
|
||||||
var printDataJson = result.TryGetProperty("printData", out var pdEl)
|
|
||||||
? pdEl.GetRawText()
|
|
||||||
: "{}";
|
|
||||||
if (string.IsNullOrWhiteSpace(templateJson))
|
|
||||||
return (string.Empty, "{}", "服务端未返回模板 JSON,请先在「业务打印绑定」中配置原材料卡片");
|
|
||||||
return (templateJson!, printDataJson, null);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
_logger.Warning($"[原材料卡片] 准备打印数据失败 id={id}: {ex.Message}");
|
// 映射异常时继续按模板字段补空,避免影响整体打印
|
||||||
return (string.Empty, "{}", $"获取打印数据失败:{ex.Message}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var templateNode = JsonNode.Parse(templateJson);
|
||||||
|
var bindFields = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
CollectTemplateBindFields(templateNode, bindFields);
|
||||||
|
foreach (var key in bindFields)
|
||||||
|
{
|
||||||
|
if (!HasPath(printData, key))
|
||||||
|
{
|
||||||
|
SetPath(printData, key, JsonValue.Create(string.Empty)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 模板结构异常时忽略补空逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
return printData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectTemplateBindFields(JsonNode? node, HashSet<string> fields)
|
||||||
|
{
|
||||||
|
if (node == null) return;
|
||||||
|
if (node is JsonObject obj)
|
||||||
|
{
|
||||||
|
if (obj["dataBinding"] is JsonObject db && db["params"] is JsonArray paramArr)
|
||||||
|
{
|
||||||
|
foreach (var p in paramArr)
|
||||||
|
{
|
||||||
|
var key = p?["key"]?.GetValue<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(key)) fields.Add(key!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bindField = obj["bindField"]?.GetValue<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(bindField)) fields.Add(bindField!);
|
||||||
|
|
||||||
|
if (obj["columns"] is JsonArray cols)
|
||||||
|
{
|
||||||
|
foreach (var c in cols)
|
||||||
|
{
|
||||||
|
var cBind = c?["bindField"]?.GetValue<string>()?.Trim();
|
||||||
|
var cField = c?["field"]?.GetValue<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cBind)) fields.Add(cBind!);
|
||||||
|
else if (!string.IsNullOrWhiteSpace(cField)) fields.Add(cField!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kv in obj)
|
||||||
|
{
|
||||||
|
CollectTemplateBindFields(kv.Value, fields);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is JsonArray arr)
|
||||||
|
{
|
||||||
|
foreach (var item in arr)
|
||||||
|
{
|
||||||
|
CollectTemplateBindFields(item, fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode? ResolvePath(JsonNode? root, string path)
|
||||||
|
{
|
||||||
|
if (root == null || string.IsNullOrWhiteSpace(path)) return null;
|
||||||
|
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
JsonNode? current = root;
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (current == null) return null;
|
||||||
|
if (current is JsonArray arr)
|
||||||
|
{
|
||||||
|
if (int.TryParse(part, out var index))
|
||||||
|
{
|
||||||
|
current = index >= 0 && index < arr.Count ? arr[index] : null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current = arr.Count > 0 ? arr[0]?[part] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPath(JsonObject root, string path)
|
||||||
|
{
|
||||||
|
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
JsonNode? current = root;
|
||||||
|
for (int i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
if (current is not JsonObject obj) return false;
|
||||||
|
if (!obj.TryGetPropertyValue(parts[i], out current)) return false;
|
||||||
|
if (i == parts.Length - 1) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode NormalizePrintNodeValue(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node == null) return JsonValue.Create(string.Empty)!;
|
||||||
|
if (node is JsonValue value)
|
||||||
|
{
|
||||||
|
if (value.TryGetValue<DateTime>(out var dt))
|
||||||
|
{
|
||||||
|
return JsonValue.Create(dt.ToString("yyyy-MM-dd HH:mm:ss"))!;
|
||||||
|
}
|
||||||
|
if (value.TryGetValue<DateTimeOffset>(out var dto))
|
||||||
|
{
|
||||||
|
return JsonValue.Create(dto.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
|
||||||
|
}
|
||||||
|
if (value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
if ((text.Contains('T') || text.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || text.Contains('+'))
|
||||||
|
&& DateTimeOffset.TryParse(text, out var parsed))
|
||||||
|
{
|
||||||
|
return JsonValue.Create(parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node.DeepClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetPath(JsonObject target, string path, JsonNode value)
|
||||||
|
{
|
||||||
|
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length == 0) return;
|
||||||
|
|
||||||
|
JsonObject current = target;
|
||||||
|
for (int i = 0; i < parts.Length - 1; i++)
|
||||||
|
{
|
||||||
|
if (current[parts[i]] is not JsonObject child)
|
||||||
|
{
|
||||||
|
child = new JsonObject();
|
||||||
|
current[parts[i]] = child;
|
||||||
|
}
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
|
current[parts[^1]] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> UpdatePriorityAsync(string id, string priorityPickup, CancellationToken ct = default)
|
public async Task<bool> UpdatePriorityAsync(string id, string priorityPickup, CancellationToken ct = default)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Net.Http;
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Web;
|
using System.Web;
|
||||||
using Prism.Events;
|
using Prism.Events;
|
||||||
@@ -20,6 +21,8 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen
|
|||||||
private readonly INetworkMonitor _networkMonitor;
|
private readonly INetworkMonitor _networkMonitor;
|
||||||
private readonly IEventAggregator _eventAggregator;
|
private readonly IEventAggregator _eventAggregator;
|
||||||
private readonly ILoggerService _logger;
|
private readonly ILoggerService _logger;
|
||||||
|
private readonly IPrintBizTemplateBindService _printBizTemplateBindService;
|
||||||
|
private readonly IPrintTemplateService _printTemplateService;
|
||||||
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
private readonly SemaphoreSlim _syncLock = new(1, 1);
|
||||||
private readonly object _cacheLock = new();
|
private readonly object _cacheLock = new();
|
||||||
private readonly string _pendingOpsFilePath;
|
private readonly string _pendingOpsFilePath;
|
||||||
@@ -39,13 +42,17 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
INetworkMonitor networkMonitor,
|
INetworkMonitor networkMonitor,
|
||||||
IEventAggregator eventAggregator,
|
IEventAggregator eventAggregator,
|
||||||
ILoggerService logger)
|
ILoggerService logger,
|
||||||
|
IPrintBizTemplateBindService printBizTemplateBindService,
|
||||||
|
IPrintTemplateService printTemplateService)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_networkMonitor = networkMonitor;
|
_networkMonitor = networkMonitor;
|
||||||
_eventAggregator = eventAggregator;
|
_eventAggregator = eventAggregator;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_printBizTemplateBindService = printBizTemplateBindService;
|
||||||
|
_printTemplateService = printTemplateService;
|
||||||
|
|
||||||
var appDataDir = Path.Combine(
|
var appDataDir = Path.Combine(
|
||||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
@@ -66,6 +73,8 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen
|
|||||||
}
|
}
|
||||||
|
|
||||||
private const int MaxPendingRetries = 5;
|
private const int MaxPendingRetries = 5;
|
||||||
|
private const string RawMaterialEntryBizCode = "1900000000000000530";
|
||||||
|
private const string RawMaterialEntryTemplateCode = "MES_RAW_MATERIAL_ENTRY";
|
||||||
private string BaseUrl => (_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
|
private string BaseUrl => (_configuration.GetValue<string>("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/');
|
||||||
private int DefaultTenantId => (int?)_configuration.GetValue<long?>("JeecgIntegration:DefaultTenantId") ?? 1002;
|
private int DefaultTenantId => (int?)_configuration.GetValue<long?>("JeecgIntegration:DefaultTenantId") ?? 1002;
|
||||||
|
|
||||||
@@ -239,35 +248,264 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen
|
|||||||
|
|
||||||
public async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default)
|
public async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!_networkMonitor.IsOnline)
|
if (_networkMonitor.IsOnline)
|
||||||
return (string.Empty, "{}", "当前离线,无法获取打印数据");
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/prepareNativePrint?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
||||||
|
using var client = CreateClient();
|
||||||
|
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
||||||
|
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
if (!root.TryGetProperty("code", out var codeEl) || codeEl.GetInt32() != 200)
|
||||||
|
{
|
||||||
|
var msg = root.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "未知错误";
|
||||||
|
return (string.Empty, "{}", msg ?? "服务端返回错误");
|
||||||
|
}
|
||||||
|
var result = root.GetProperty("result");
|
||||||
|
var templateJson = result.TryGetProperty("templateJson", out var tjEl) ? tjEl.GetString() : null;
|
||||||
|
var printDataJson = result.TryGetProperty("printData", out var pdEl)
|
||||||
|
? pdEl.GetRawText()
|
||||||
|
: "{}";
|
||||||
|
if (string.IsNullOrWhiteSpace(templateJson))
|
||||||
|
return (string.Empty, "{}", "服务端未返回模板 JSON,请先在「业务打印绑定」中配置原料入场记录");
|
||||||
|
return (templateJson!, printDataJson, null);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.Warning($"[原料入场] 远端准备打印数据失败,回退本地模板渲染 id={id}: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await PrepareLocalNativePrintAsync(id, ct).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareLocalNativePrintAsync(string id, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var entry = GetEntrySnapshotById(id);
|
||||||
|
if (entry == null)
|
||||||
|
{
|
||||||
|
return (string.Empty, "{}", "本地未找到入场记录,无法离线打印");
|
||||||
|
}
|
||||||
|
|
||||||
|
var bindList = _printBizTemplateBindService.GetCached();
|
||||||
|
if (bindList.Count == 0)
|
||||||
|
{
|
||||||
|
try { bindList = await _printBizTemplateBindService.ListAsync(ct).ConfigureAwait(false); } catch { }
|
||||||
|
}
|
||||||
|
var bind = bindList.FirstOrDefault(x => string.Equals(x.BizCode, RawMaterialEntryBizCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? bindList.FirstOrDefault(x => string.Equals(x.TemplateCode, RawMaterialEntryTemplateCode, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? bindList.FirstOrDefault(x => (x.BizName ?? string.Empty).Contains("原料入场记录", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (bind == null)
|
||||||
|
{
|
||||||
|
return (string.Empty, "{}", "未找到本地业务打印绑定,请先在线同步「原料入场记录」模板配置");
|
||||||
|
}
|
||||||
|
|
||||||
|
var templates = _printTemplateService.GetCached();
|
||||||
|
if (templates.Count == 0)
|
||||||
|
{
|
||||||
|
try { templates = await _printTemplateService.ListAsync(ct).ConfigureAwait(false); } catch { }
|
||||||
|
}
|
||||||
|
var templateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialEntryTemplateCode : bind.TemplateCode!;
|
||||||
|
var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, templateCode, StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson))
|
||||||
|
{
|
||||||
|
return (string.Empty, "{}", "本地未找到打印模板,请先在线同步模板后再离线打印");
|
||||||
|
}
|
||||||
|
|
||||||
|
var mappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!;
|
||||||
|
var printData = BuildPrintDataFromMapping(entry, mappingJson, tpl.TemplateJson!);
|
||||||
|
return (tpl.TemplateJson!, printData.ToJsonString(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private MesXslRawMaterialEntry? GetEntrySnapshotById(string id)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(id)) return null;
|
||||||
|
lock (_cacheLock)
|
||||||
|
{
|
||||||
|
var snapshot = ApplyPendingOpsSnapshotUnsafe(_localCache.Select(Clone).ToList());
|
||||||
|
return snapshot.FirstOrDefault(e => string.Equals(e.Id, id, StringComparison.OrdinalIgnoreCase)) is { } found
|
||||||
|
? Clone(found)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private JsonObject BuildPrintDataFromMapping<T>(T source, string mappingJson, string templateJson)
|
||||||
|
{
|
||||||
|
JsonObject printData = new();
|
||||||
|
JsonNode? bizRoot = JsonSerializer.SerializeToNode(source, _jsonOpts);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/prepareNativePrint?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}";
|
var mappingNode = JsonNode.Parse(mappingJson) as JsonArray;
|
||||||
using var client = CreateClient();
|
if (mappingNode != null)
|
||||||
var resp = await client.GetAsync(url, ct).ConfigureAwait(false);
|
|
||||||
var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
if (!root.TryGetProperty("code", out var codeEl) || codeEl.GetInt32() != 200)
|
|
||||||
{
|
{
|
||||||
var msg = root.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "未知错误";
|
foreach (var rule in mappingNode)
|
||||||
return (string.Empty, "{}", msg ?? "服务端返回错误");
|
{
|
||||||
|
if (rule is not JsonObject obj) continue;
|
||||||
|
var templateField = obj["templateField"]?.GetValue<string>()?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(templateField)) continue;
|
||||||
|
var bizField = obj["bizField"]?.GetValue<string>()?.Trim();
|
||||||
|
JsonNode? value = string.IsNullOrWhiteSpace(bizField)
|
||||||
|
? JsonValue.Create(string.Empty)
|
||||||
|
: ResolvePath(bizRoot, bizField!);
|
||||||
|
SetPath(printData, templateField!, NormalizePrintNodeValue(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
var result = root.GetProperty("result");
|
|
||||||
var templateJson = result.TryGetProperty("templateJson", out var tjEl) ? tjEl.GetString() : null;
|
|
||||||
var printDataJson = result.TryGetProperty("printData", out var pdEl)
|
|
||||||
? pdEl.GetRawText()
|
|
||||||
: "{}";
|
|
||||||
if (string.IsNullOrWhiteSpace(templateJson))
|
|
||||||
return (string.Empty, "{}", "服务端未返回模板 JSON,请先在「业务打印绑定」中配置原料入场记录");
|
|
||||||
return (templateJson!, printDataJson, null);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
_logger.Warning($"[原料入场] 准备打印数据失败 id={id}: {ex.Message}");
|
// 映射异常时继续按模板字段补空,避免影响整体打印
|
||||||
return (string.Empty, "{}", $"获取打印数据失败:{ex.Message}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var templateNode = JsonNode.Parse(templateJson);
|
||||||
|
var bindFields = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
CollectTemplateBindFields(templateNode, bindFields);
|
||||||
|
foreach (var key in bindFields)
|
||||||
|
{
|
||||||
|
if (!HasPath(printData, key))
|
||||||
|
{
|
||||||
|
SetPath(printData, key, JsonValue.Create(string.Empty)!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 模板结构异常时忽略补空逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
return printData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectTemplateBindFields(JsonNode? node, HashSet<string> fields)
|
||||||
|
{
|
||||||
|
if (node == null) return;
|
||||||
|
if (node is JsonObject obj)
|
||||||
|
{
|
||||||
|
if (obj["dataBinding"] is JsonObject db && db["params"] is JsonArray paramArr)
|
||||||
|
{
|
||||||
|
foreach (var p in paramArr)
|
||||||
|
{
|
||||||
|
var key = p?["key"]?.GetValue<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(key)) fields.Add(key!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bindField = obj["bindField"]?.GetValue<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(bindField)) fields.Add(bindField!);
|
||||||
|
|
||||||
|
if (obj["columns"] is JsonArray cols)
|
||||||
|
{
|
||||||
|
foreach (var c in cols)
|
||||||
|
{
|
||||||
|
var cBind = c?["bindField"]?.GetValue<string>()?.Trim();
|
||||||
|
var cField = c?["field"]?.GetValue<string>()?.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(cBind)) fields.Add(cBind!);
|
||||||
|
else if (!string.IsNullOrWhiteSpace(cField)) fields.Add(cField!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var kv in obj)
|
||||||
|
{
|
||||||
|
CollectTemplateBindFields(kv.Value, fields);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node is JsonArray arr)
|
||||||
|
{
|
||||||
|
foreach (var item in arr)
|
||||||
|
{
|
||||||
|
CollectTemplateBindFields(item, fields);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode? ResolvePath(JsonNode? root, string path)
|
||||||
|
{
|
||||||
|
if (root == null || string.IsNullOrWhiteSpace(path)) return null;
|
||||||
|
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
JsonNode? current = root;
|
||||||
|
foreach (var part in parts)
|
||||||
|
{
|
||||||
|
if (current == null) return null;
|
||||||
|
if (current is JsonArray arr)
|
||||||
|
{
|
||||||
|
if (int.TryParse(part, out var index))
|
||||||
|
{
|
||||||
|
current = index >= 0 && index < arr.Count ? arr[index] : null;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current = arr.Count > 0 ? arr[0]?[part] : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
current = current[part];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool HasPath(JsonObject root, string path)
|
||||||
|
{
|
||||||
|
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
JsonNode? current = root;
|
||||||
|
for (int i = 0; i < parts.Length; i++)
|
||||||
|
{
|
||||||
|
if (current is not JsonObject obj) return false;
|
||||||
|
if (!obj.TryGetPropertyValue(parts[i], out current)) return false;
|
||||||
|
if (i == parts.Length - 1) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonNode NormalizePrintNodeValue(JsonNode? node)
|
||||||
|
{
|
||||||
|
if (node == null) return JsonValue.Create(string.Empty)!;
|
||||||
|
if (node is JsonValue value)
|
||||||
|
{
|
||||||
|
if (value.TryGetValue<DateTime>(out var dt))
|
||||||
|
{
|
||||||
|
return JsonValue.Create(dt.ToString("yyyy-MM-dd HH:mm:ss"))!;
|
||||||
|
}
|
||||||
|
if (value.TryGetValue<DateTimeOffset>(out var dto))
|
||||||
|
{
|
||||||
|
return JsonValue.Create(dto.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
|
||||||
|
}
|
||||||
|
if (value.TryGetValue<string>(out var text) && !string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
if ((text.Contains('T') || text.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || text.Contains('+'))
|
||||||
|
&& DateTimeOffset.TryParse(text, out var parsed))
|
||||||
|
{
|
||||||
|
return JsonValue.Create(parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node.DeepClone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void SetPath(JsonObject target, string path, JsonNode value)
|
||||||
|
{
|
||||||
|
var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
if (parts.Length == 0) return;
|
||||||
|
|
||||||
|
JsonObject current = target;
|
||||||
|
for (int i = 0; i < parts.Length - 1; i++)
|
||||||
|
{
|
||||||
|
if (current[parts[i]] is not JsonObject child)
|
||||||
|
{
|
||||||
|
child = new JsonObject();
|
||||||
|
current[parts[i]] = child;
|
||||||
|
}
|
||||||
|
current = child;
|
||||||
|
}
|
||||||
|
current[parts[^1]] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
public IReadOnlyList<MesXslRawMaterialEntry> GetCachedSnapshot()
|
public IReadOnlyList<MesXslRawMaterialEntry> GetCachedSnapshot()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using YY.Admin.Core.Helper;
|
using YY.Admin.Core.Helper;
|
||||||
using YY.Admin.Core.Session;
|
using YY.Admin.Core.Session;
|
||||||
using YY.Admin.FluentValidation;
|
using YY.Admin.FluentValidation;
|
||||||
|
using YY.Admin.Helper;
|
||||||
using YY.Admin.Services;
|
using YY.Admin.Services;
|
||||||
using YY.Admin.Services.Service.Auth;
|
using YY.Admin.Services.Service.Auth;
|
||||||
using YY.Admin.Services.Service.Jeecg;
|
using YY.Admin.Services.Service.Jeecg;
|
||||||
@@ -220,6 +221,31 @@ namespace YY.Admin.ViewModels
|
|||||||
while (!cancellationToken.IsCancellationRequested)
|
while (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
bool connected = false;
|
bool connected = false;
|
||||||
|
var settings = ServerSettingsStore.Load();
|
||||||
|
if (settings.DisconnectConnection)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Application.Current.Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
IsBackendConnected = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// 忽略窗口关闭后的调度异常
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(ConnectivityCheckIntervalSeconds), cancellationToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// 每轮都重新读取配置,保存服务器设置后可即时生效
|
// 每轮都重新读取配置,保存服务器设置后可即时生效
|
||||||
@@ -269,6 +295,11 @@ namespace YY.Admin.ViewModels
|
|||||||
{
|
{
|
||||||
if (r.Result == ButtonResult.OK)
|
if (r.Result == ButtonResult.OK)
|
||||||
{
|
{
|
||||||
|
var settings = ServerSettingsStore.Load();
|
||||||
|
if (settings.DisconnectConnection)
|
||||||
|
{
|
||||||
|
IsBackendConnected = false;
|
||||||
|
}
|
||||||
LoginMessage = "服务器配置已保存";
|
LoginMessage = "服务器配置已保存";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
|
|||||||
public DelegateCommand ResplitCommand { get; }
|
public DelegateCommand ResplitCommand { get; }
|
||||||
public DelegateCommand SaveAndPrintCommand { get; }
|
public DelegateCommand SaveAndPrintCommand { get; }
|
||||||
public DelegateCommand RefreshPrintersCommand { get; }
|
public DelegateCommand RefreshPrintersCommand { get; }
|
||||||
|
public DelegateCommand ZoomOutPrintPreviewCommand { get; }
|
||||||
|
public DelegateCommand ZoomInPrintPreviewCommand { get; }
|
||||||
|
public DelegateCommand ResetPrintPreviewZoomCommand { get; }
|
||||||
|
|
||||||
/// <summary>PrintDot 桥接器返回的打印机列表(与打印模板页一致)。</summary>
|
/// <summary>PrintDot 桥接器返回的打印机列表(与打印模板页一致)。</summary>
|
||||||
public ObservableCollection<PrintDotPrinter> Printers { get; } = new();
|
public ObservableCollection<PrintDotPrinter> Printers { get; } = new();
|
||||||
@@ -169,6 +172,27 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
|
|||||||
private set => SetProperty(ref _printPreviewStatus, value);
|
private set => SetProperty(ref _printPreviewStatus, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const double PrintPreviewMinZoom = 0.5d;
|
||||||
|
private const double PrintPreviewMaxZoom = 2.0d;
|
||||||
|
private const double PrintPreviewDefaultZoom = 0.7d;
|
||||||
|
private const double PrintPreviewZoomStep = 0.1d;
|
||||||
|
|
||||||
|
private double _printPreviewZoomFactor = PrintPreviewDefaultZoom;
|
||||||
|
/// <summary>打印预览缩放倍率(WebView2 ZoomFactor)。</summary>
|
||||||
|
public double PrintPreviewZoomFactor
|
||||||
|
{
|
||||||
|
get => _printPreviewZoomFactor;
|
||||||
|
set
|
||||||
|
{
|
||||||
|
var clamped = Math.Round(Math.Clamp(value, PrintPreviewMinZoom, PrintPreviewMaxZoom), 2);
|
||||||
|
if (!SetProperty(ref _printPreviewZoomFactor, clamped)) return;
|
||||||
|
RaisePropertyChanged(nameof(PrintPreviewZoomText));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>打印预览缩放显示文本(百分比)。</summary>
|
||||||
|
public string PrintPreviewZoomText => $"{Math.Round(PrintPreviewZoomFactor * 100):0}%";
|
||||||
|
|
||||||
/// <summary>由 View 订阅,在 UI 线程将 HTML 交给 WebView2。</summary>
|
/// <summary>由 View 订阅,在 UI 线程将 HTML 交给 WebView2。</summary>
|
||||||
public event EventHandler<string>? PrintPreviewHtmlReady;
|
public event EventHandler<string>? PrintPreviewHtmlReady;
|
||||||
|
|
||||||
@@ -220,6 +244,9 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
|
|||||||
.ObservesProperty(() => Entry);
|
.ObservesProperty(() => Entry);
|
||||||
SaveAndPrintCommand = new DelegateCommand(async () => await SaveAndPrintAsync());
|
SaveAndPrintCommand = new DelegateCommand(async () => await SaveAndPrintAsync());
|
||||||
RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true));
|
RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true));
|
||||||
|
ZoomOutPrintPreviewCommand = new DelegateCommand(() => PrintPreviewZoomFactor -= PrintPreviewZoomStep);
|
||||||
|
ZoomInPrintPreviewCommand = new DelegateCommand(() => PrintPreviewZoomFactor += PrintPreviewZoomStep);
|
||||||
|
ResetPrintPreviewZoomCommand = new DelegateCommand(() => PrintPreviewZoomFactor = PrintPreviewDefaultZoom);
|
||||||
// 集合变化:批量重订阅 item.PropertyChanged 监听 HasCard/Portions,并同步刷新两个 Can*。
|
// 集合变化:批量重订阅 item.PropertyChanged 监听 HasCard/Portions,并同步刷新两个 Can*。
|
||||||
SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChangedForCanFlags;
|
SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChangedForCanFlags;
|
||||||
_ = RefreshPrintersAsync(verbose: false);
|
_ = RefreshPrintersAsync(verbose: false);
|
||||||
@@ -808,7 +835,8 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView
|
|||||||
string html;
|
string html;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
html = NativePrintRenderService.RenderToHtml(_previewTemplateJson, dataObj);
|
// 实时预览关闭模板内部 fitPage 自适应,避免与 WebView2 外层缩放叠加后出现“越放大越小”。
|
||||||
|
html = NativePrintRenderService.RenderToHtml(_previewTemplateJson, dataObj, enableScreenAutoFit: false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1057,42 +1057,76 @@
|
|||||||
Margin="8,0,10,0"
|
Margin="8,0,10,0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource SecondaryTextBrush}"/>
|
Foreground="{DynamicResource SecondaryTextBrush}"/>
|
||||||
<ToggleButton Grid.Column="2"
|
<StackPanel Grid.Column="2"
|
||||||
IsChecked="{Binding IsPrintPreviewExpanded, Mode=TwoWay}"
|
Orientation="Horizontal"
|
||||||
MinWidth="56"
|
VerticalAlignment="Center"
|
||||||
Height="24"
|
HorizontalAlignment="Right">
|
||||||
Background="Transparent"
|
<StackPanel Orientation="Horizontal"
|
||||||
BorderThickness="1"
|
Margin="0,0,8,0"
|
||||||
Padding="8,0"
|
VerticalAlignment="Center"
|
||||||
FocusVisualStyle="{x:Null}"
|
Visibility="{Binding IsPrintPreviewExpanded, Converter={StaticResource Boolean2VisibilityConverter}}">
|
||||||
Cursor="Hand"
|
<Button Content="-"
|
||||||
VerticalAlignment="Center">
|
Width="24"
|
||||||
<ToggleButton.Template>
|
Height="24"
|
||||||
<ControlTemplate TargetType="ToggleButton">
|
Padding="0"
|
||||||
<Border Background="{TemplateBinding Background}"
|
FontSize="12"
|
||||||
BorderBrush="{DynamicResource BorderBrush}"
|
Command="{Binding ZoomOutPrintPreviewCommand}"
|
||||||
BorderThickness="{TemplateBinding BorderThickness}"
|
Style="{StaticResource ButtonDefault}"
|
||||||
CornerRadius="2"
|
ToolTip="缩小预览"/>
|
||||||
Padding="{TemplateBinding Padding}">
|
<Button Content="{Binding PrintPreviewZoomText}"
|
||||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
MinWidth="56"
|
||||||
</Border>
|
Height="24"
|
||||||
</ControlTemplate>
|
Margin="4,0,4,0"
|
||||||
</ToggleButton.Template>
|
Padding="10,0"
|
||||||
<TextBlock FontSize="12"
|
FontSize="11"
|
||||||
VerticalAlignment="Center"
|
Command="{Binding ResetPrintPreviewZoomCommand}"
|
||||||
HorizontalAlignment="Center">
|
Style="{StaticResource ButtonDefault}"
|
||||||
<TextBlock.Style>
|
ToolTip="重置为 70%"/>
|
||||||
<Style TargetType="TextBlock">
|
<Button Content="+"
|
||||||
<Setter Property="Text" Value="展开"/>
|
Width="24"
|
||||||
<Style.Triggers>
|
Height="24"
|
||||||
<DataTrigger Binding="{Binding IsChecked, RelativeSource={RelativeSource AncestorType=ToggleButton}}" Value="True">
|
Padding="0"
|
||||||
<Setter Property="Text" Value="折叠"/>
|
FontSize="12"
|
||||||
</DataTrigger>
|
Command="{Binding ZoomInPrintPreviewCommand}"
|
||||||
</Style.Triggers>
|
Style="{StaticResource ButtonDefault}"
|
||||||
</Style>
|
ToolTip="放大预览"/>
|
||||||
</TextBlock.Style>
|
</StackPanel>
|
||||||
</TextBlock>
|
<ToggleButton IsChecked="{Binding IsPrintPreviewExpanded, Mode=TwoWay}"
|
||||||
</ToggleButton>
|
MinWidth="56"
|
||||||
|
Height="24"
|
||||||
|
Background="Transparent"
|
||||||
|
BorderThickness="1"
|
||||||
|
Padding="8,0"
|
||||||
|
FocusVisualStyle="{x:Null}"
|
||||||
|
Cursor="Hand"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<ToggleButton.Template>
|
||||||
|
<ControlTemplate TargetType="ToggleButton">
|
||||||
|
<Border Background="{TemplateBinding Background}"
|
||||||
|
BorderBrush="{DynamicResource BorderBrush}"
|
||||||
|
BorderThickness="{TemplateBinding BorderThickness}"
|
||||||
|
CornerRadius="2"
|
||||||
|
Padding="{TemplateBinding Padding}">
|
||||||
|
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ToggleButton.Template>
|
||||||
|
<TextBlock FontSize="12"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
HorizontalAlignment="Center">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Text" Value="展开"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding IsChecked, RelativeSource={RelativeSource AncestorType=ToggleButton}}" Value="True">
|
||||||
|
<Setter Property="Text" Value="折叠"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
</ToggleButton>
|
||||||
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
<Border BorderBrush="{DynamicResource BorderBrush}"
|
<Border BorderBrush="{DynamicResource BorderBrush}"
|
||||||
|
|||||||
@@ -49,10 +49,22 @@ public partial class RawMaterialEntryOperationView : UserControl
|
|||||||
// null/空 表示“所有属性”通知(Prism/BindableBase 批量刷新时),需同步分割布局
|
// null/空 表示“所有属性”通知(Prism/BindableBase 批量刷新时),需同步分割布局
|
||||||
if (!string.IsNullOrEmpty(e.PropertyName)
|
if (!string.IsNullOrEmpty(e.PropertyName)
|
||||||
&& e.PropertyName is not nameof(RawMaterialEntryOperationViewModel.IsRightPanelExpanded)
|
&& e.PropertyName is not nameof(RawMaterialEntryOperationViewModel.IsRightPanelExpanded)
|
||||||
&& e.PropertyName is not nameof(RawMaterialEntryOperationViewModel.ExpandedRightPanelWidth))
|
&& e.PropertyName is not nameof(RawMaterialEntryOperationViewModel.ExpandedRightPanelWidth)
|
||||||
|
&& e.PropertyName is not nameof(RawMaterialEntryOperationViewModel.PrintPreviewZoomFactor))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
_ = Dispatcher.InvokeAsync(ApplySplitLayout);
|
if (string.IsNullOrEmpty(e.PropertyName)
|
||||||
|
|| e.PropertyName is nameof(RawMaterialEntryOperationViewModel.IsRightPanelExpanded)
|
||||||
|
|| e.PropertyName is nameof(RawMaterialEntryOperationViewModel.ExpandedRightPanelWidth))
|
||||||
|
{
|
||||||
|
_ = Dispatcher.InvokeAsync(ApplySplitLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(e.PropertyName)
|
||||||
|
|| e.PropertyName is nameof(RawMaterialEntryOperationViewModel.PrintPreviewZoomFactor))
|
||||||
|
{
|
||||||
|
_ = Dispatcher.InvokeAsync(ApplyPrintPreviewZoom);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>按钮 Click 在 Command 之后执行,用于兜底刷新列宽(不重复切换状态)。</summary>
|
/// <summary>按钮 Click 在 Command 之后执行,用于兜底刷新列宽(不重复切换状态)。</summary>
|
||||||
@@ -66,6 +78,7 @@ public partial class RawMaterialEntryOperationView : UserControl
|
|||||||
EnsureVmAttached();
|
EnsureVmAttached();
|
||||||
|
|
||||||
ApplySplitLayout();
|
ApplySplitLayout();
|
||||||
|
ApplyPrintPreviewZoom();
|
||||||
|
|
||||||
if (DataContext is RawMaterialEntryOperationViewModel vm && !_initialized)
|
if (DataContext is RawMaterialEntryOperationViewModel vm && !_initialized)
|
||||||
{
|
{
|
||||||
@@ -92,6 +105,7 @@ public partial class RawMaterialEntryOperationView : UserControl
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await PrintPreviewWebView.EnsureCoreWebView2Async();
|
await PrintPreviewWebView.EnsureCoreWebView2Async();
|
||||||
|
ApplyPrintPreviewZoom();
|
||||||
PrintPreviewWebView.NavigateToString(html ?? string.Empty);
|
PrintPreviewWebView.NavigateToString(html ?? string.Empty);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -100,6 +114,15 @@ public partial class RawMaterialEntryOperationView : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ApplyPrintPreviewZoom()
|
||||||
|
{
|
||||||
|
var vm = _vm ?? DataContext as RawMaterialEntryOperationViewModel;
|
||||||
|
if (vm == null) return;
|
||||||
|
if (PrintPreviewWebView?.CoreWebView2 == null) return;
|
||||||
|
// 实时预览已关闭 HTML 内部 fitPage,自定义缩放直接映射到 WebView2 即可(+ 放大,- 缩小)。
|
||||||
|
PrintPreviewWebView.ZoomFactor = vm.PrintPreviewZoomFactor;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 根据 ViewModel 同步右侧栏与分割条:展开时使用持久化宽度;折叠时右栏与分割条占宽均为 0(完全隐藏)。
|
/// 根据 ViewModel 同步右侧栏与分割条:展开时使用持久化宽度;折叠时右栏与分割条占宽均为 0(完全隐藏)。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user