using System.Globalization; using System.IO; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; using System.Windows; using YY.Admin.Core.Entity; using YY.Admin.Core.Services; using YY.Admin.Helper; using YY.Admin.Services.Service.Print; namespace YY.Admin.Views.Print; public partial class PrintPreviewWindow : HandyControl.Controls.Window { private readonly string _templateJson; private readonly PrintTemplate _template; private readonly IPrintDotService? _printDotService; private readonly string? _initialPrinterName; public PrintPreviewWindow(PrintTemplate template, string? templateJson) : this(template, templateJson, null, null) { } public PrintPreviewWindow(PrintTemplate template, string? templateJson, IPrintDotService? printDotService, string? selectedPrinterName, string? initialParamJson = null) { InitializeComponent(); _template = template; _templateJson = templateJson ?? string.Empty; _printDotService = printDotService; _initialPrinterName = selectedPrinterName; WebView.CreationProperties = WebView2UserDataFolder.CreateCreationProperties("PrintPreview"); TbTemplateName.Text = template.TemplateName ?? "(未命名)"; TbTemplateCode.Text = $"编码:{template.TemplateCode} " + $"尺寸:{template.PaperWidthMm ?? 210}×{template.PaperHeightMm ?? 297} mm " + $"方向:{template.PaperOrientation ?? "纵向"}"; TbParamJson.Text = !string.IsNullOrWhiteSpace(initialParamJson) ? initialParamJson! : BuildMockParamJson(_templateJson); // 没有 PrintDot 服务时禁用打印相关按钮 if (_printDotService == null) { BtnPrint.IsEnabled = false; BtnRefreshPrinters.IsEnabled = false; PrinterCombo.IsEnabled = false; } Loaded += async (_, _) => { await LoadPreviewAsync(); await LoadPrintersAsync(verbose: false); }; } private async Task LoadPreviewAsync() { try { SetStatus("加载中…"); await WebView.EnsureCoreWebView2Async(); if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}") { WebView.NavigateToString(BuildEmptyHtml()); SetStatus("尚未设计模板内容"); return; } await RenderCurrentParamJsonAsync(); } catch (Exception ex) { SetStatus($"预览失败:{ex.Message}"); } } private static string BuildEmptyHtml() => """
📄
该模板尚未设计内容
"""; /// /// 左侧参数 JSON 重新渲染预览(与后端预览一致:用参数 JSON 驱动模板绑定字段)。 /// private async Task RenderCurrentParamJsonAsync() { try { SetStatus("渲染中…"); JsonObject dataObj; var text = TbParamJson.Text?.Trim(); if (string.IsNullOrWhiteSpace(text)) { dataObj = new JsonObject(); } else { var node = JsonNode.Parse(text); if (node is not JsonObject obj) { WebView.NavigateToString(BuildErrorHtml("参数JSON必须是对象(JSON Object)")); SetStatus("参数JSON格式错误"); return; } dataObj = obj; } var html = NativePrintRenderService.RenderToHtml(_templateJson, dataObj); WebView.NavigateToString(html); SetStatus(string.Empty); } catch (Exception ex) { WebView.NavigateToString(BuildErrorHtml(ex.Message)); SetStatus($"渲染失败:{ex.Message}"); } await Task.CompletedTask; } /// /// 根据模板绑定字段生成参数 JSON(便于用户直接编辑并预览)。 /// 与 web 端 nativeMockData.ts 保持一致:识别 mergeColumnKeys 让同组相邻行字段值相同, /// 以便在预览中触发 rowSpan 合并显示。 /// private static string BuildMockParamJson(string templateJson) { if (string.IsNullOrWhiteSpace(templateJson) || templateJson == "{}") return "{}"; try { var root = JsonNode.Parse(templateJson); var obj = new JsonObject(); var elements = root?["elements"]?.AsArray() ?? new JsonArray(); var fields = new HashSet(StringComparer.OrdinalIgnoreCase); var rng = new Random(); foreach (var el in elements.OfType()) { var type = (el["type"]?.ToString() ?? string.Empty).Trim(); if (type is "table" or "detailTable") { var source = (el["source"]?.ToString() ?? "mainTable").Trim(); if (!obj.ContainsKey(source)) { var columns = el["columns"]?.AsArray() ?? new JsonArray(); var colList = columns.OfType().ToList(); // 解析合并字段顺序:根据 mergeColumnKeys 按 column.key 映射到 bindField var mergeKeys = (el["mergeColumnKeys"]?.AsArray() ?? new JsonArray()) .Select(n => n?.ToString() ?? string.Empty) .Where(s => !string.IsNullOrEmpty(s)) .ToList(); var strictGrouping = !string.Equals( el["strictGrouping"]?.ToString() ?? "true", "false", StringComparison.OrdinalIgnoreCase); var mergeFieldOrder = mergeKeys .Select(k => colList.FirstOrDefault(c => string.Equals(c["key"]?.ToString() ?? string.Empty, k, StringComparison.Ordinal))) .Where(c => c != null) .Select(c => (c!["bindField"]?.ToString() ?? c!["field"]?.ToString() ?? string.Empty).Trim()) .Where(s => !string.IsNullOrEmpty(s)) .ToList(); var rows = new JsonArray(); JsonObject? prevRow = null; for (var i = 0; i < 8; i++) { var row = new JsonObject(); foreach (var col in colList) { var field = (col["bindField"]?.ToString() ?? col["field"]?.ToString() ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(field)) continue; var contentType = (col["contentType"]?.ToString() ?? "text").Trim().ToLowerInvariant(); fields.Add(field); var mergeIndex = mergeFieldOrder.IndexOf(field); var enableMerge = mergeIndex >= 0; if (enableMerge) { // 父级合并字段在 strictGrouping 下必须与前一行一致,才允许沿用前一行值 var canFollowPrev = !strictGrouping || mergeIndex == 0 || (prevRow != null && mergeFieldOrder.Take(mergeIndex).All(parent => (prevRow[parent]?.ToString() ?? string.Empty) == (row[parent]?.ToString() ?? string.Empty))); if (i > 0 && prevRow != null && canFollowPrev && rng.NextDouble() < 0.5) { // 沿用前一行此字段值,从而触发合并 var prevVal = prevRow[field]; row[field] = prevVal != null ? JsonNode.Parse(prevVal.ToJsonString()) : (JsonNode?)$"{field}_合并组1"; } else { // 新合并组:随机 0-3 表示组编号 row[field] = $"{field}_合并组{rng.Next(1, 5)}"; } continue; } row[field] = contentType switch { "number" => (i + 1) * 123.45, "amount" => (i + 1) * 24567.89, "qrcode" => BuildQrcodeMockValue(field, rng), "barcode" => BuildBarcodeMockValue(field, rng), "image" => $"https://picsum.photos/seed/{Uri.EscapeDataString(field + "_" + (i + 1))}/260/120", _ => $"{field}_示例值_{i + 1}" }; } rows.Add(row); prevRow = row; } obj[source] = rows; } } var bind = (el["bindField"]?.ToString() ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(bind)) fields.Add(bind); // 针对单一元素按 type 提前预填合法 mock 值,避免下面兜底成"字段_示例值" // (barcode 含中文会导致 Code128 编码失败)。与 web 端 nativeMockData.ts 行为对齐。 if (!string.IsNullOrWhiteSpace(bind) && !obj.ContainsKey(bind)) { switch (type) { case "barcode": obj[bind] = BuildBarcodeMockValue(bind, rng); break; case "qrcode": obj[bind] = BuildQrcodeMockValue(bind, rng); break; case "image": obj[bind] = $"https://picsum.photos/seed/{Uri.EscapeDataString(bind)}/260/120"; break; case "date": obj[bind] = "2026-01-01"; break; } } // freeTable / 其它含 cells 的元素:根据 cell.contentType 预填合法 mock 值 CollectCellsMock(el["cells"], obj, fields, rng); // 提取 text 中的 {{field}} 占位符(支持内嵌) var text = el["text"]?.ToString() ?? string.Empty; foreach (Match m in Regex.Matches(text, @"\{\{\s*([\w\.]+)\s*\}\}")) { var key = m.Groups[1].Value.Trim(); if (!string.IsNullOrWhiteSpace(key)) fields.Add(key); } } foreach (var f in fields.OrderBy(x => x, StringComparer.OrdinalIgnoreCase)) { if (f.Equals("pageNo", StringComparison.OrdinalIgnoreCase) || f.Equals("totalPages", StringComparison.OrdinalIgnoreCase)) continue; if (!obj.ContainsKey(f)) obj[f] = $"{f}_示例值"; } return obj.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); } catch { return "{}"; } } /// /// 生成一个 ASCII 安全、Code128 可编码的条码 mock 值:BAR + 12 位数字 + 字段名前 6 位转大写字母。 /// 与 web 端 nativeMockData.ts::buildBarcodeValue 同款规则。 /// private static string BuildBarcodeMockValue(string field, Random rng) { var digits = rng.NextInt64(100000000000L, 999999999999L).ToString(CultureInfo.InvariantCulture); var suffix = new string((field ?? string.Empty) .Where(c => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')) .Take(6).ToArray()).ToUpperInvariant(); if (string.IsNullOrEmpty(suffix)) suffix = "BARCOD"; return $"BAR{digits}{suffix}"; } /// 生成 QR mock 值:纯 ASCII,便于 QRCoder 编码与人眼区分字段。 private static string BuildQrcodeMockValue(string field, Random rng) { var safe = new string((field ?? string.Empty) .Where(c => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_') .ToArray()); if (string.IsNullOrEmpty(safe)) safe = "QR"; return $"QR_{safe}_{rng.Next(100000, 999999)}"; } /// /// 递归扫描 cells 节点(freeTable、嵌套结构),按每个 cell 的 contentType 提前预填合法 mock 值。 /// 不识别的 cell 继续走原来的 fields 兜底流程。 /// private static void CollectCellsMock(JsonNode? node, JsonObject obj, ISet fields, Random rng) { if (node == null) return; if (node is JsonObject o) { var bind = (o["bindField"]?.ToString() ?? string.Empty).Trim(); if (!string.IsNullOrWhiteSpace(bind)) { fields.Add(bind); if (!obj.ContainsKey(bind)) { var ct = (o["contentType"]?.ToString() ?? "text").Trim().ToLowerInvariant(); switch (ct) { case "barcode": obj[bind] = BuildBarcodeMockValue(bind, rng); break; case "qrcode": obj[bind] = BuildQrcodeMockValue(bind, rng); break; case "image": obj[bind] = $"https://picsum.photos/seed/{Uri.EscapeDataString(bind)}/260/120"; break; } } } foreach (var kv in o) CollectCellsMock(kv.Value, obj, fields, rng); return; } if (node is JsonArray arr) { foreach (var it in arr) CollectCellsMock(it, obj, fields, rng); } } private static void CollectBindFields(JsonNode? node, ISet fields) { if (node == null) return; if (node is JsonObject o) { if (o.TryGetPropertyValue("bindField", out var bindNode)) { var bind = bindNode?.ToString()?.Trim(); if (!string.IsNullOrWhiteSpace(bind)) fields.Add(bind); } foreach (var kv in o) CollectBindFields(kv.Value, fields); return; } if (node is JsonArray arr) { foreach (var it in arr) CollectBindFields(it, fields); } } private static string BuildErrorHtml(string message) { var esc = message.Replace("&", "&").Replace("<", "<").Replace(">", ">"); return "
" + "
⚠️
" + "
渲染失败:" + esc + "
" + "
"; } private async void RenderByParamJson_Click(object sender, RoutedEventArgs e) { await RenderCurrentParamJsonAsync(); } private async void GenerateMockJson_Click(object sender, RoutedEventArgs e) { TbParamJson.Text = BuildMockParamJson(_templateJson); await RenderCurrentParamJsonAsync(); } private void CloseButton_Click(object sender, RoutedEventArgs e) => Close(); /// /// 设置顶部状态栏文字:单行显示首行概要,完整多行内容通过 ToolTip 兜底,避免把工具栏撑高。 /// private void SetStatus(string? message) { var full = (message ?? string.Empty).Trim(); var firstLine = full.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault() ?? string.Empty; TbStatus.Text = firstLine; TbStatus.ToolTip = full.Contains('\n') || full.Length > firstLine.Length ? full : null; } // ── 打印机列表加载(PrintDot 桥接器) ──────────────────────────────── /// /// 通过 PrintDot 桥接器拉取打印机列表,填充顶部下拉框,并按上次选择/系统默认/首台优先选中。 /// private async Task LoadPrintersAsync(bool verbose) { if (_printDotService == null) return; try { if (verbose) SetStatus("刷新打印机中..."); var list = await _printDotService.GetPrintersAsync(); PrinterCombo.ItemsSource = list; var preferName = _initialPrinterName ?? PrintDotSettings.Load().SelectedPrinter; var match = list.FirstOrDefault(p => p.Name == preferName) ?? list.FirstOrDefault(p => p.IsDefault) ?? list.FirstOrDefault(); PrinterCombo.SelectedItem = match; SetStatus(list.Count > 0 ? $"已发现 {list.Count} 台打印机" : "未检测到打印机"); } catch (Exception ex) { PrinterCombo.ItemsSource = null; SetStatus($"PrintDot 未连接:{ex.Message}"); } } private async void RefreshPrinters_Click(object sender, RoutedEventArgs e) { await LoadPrintersAsync(verbose: true); } /// /// 打印流程: /// 1) 用 WebView2.PrintToPdfAsync 按模板纸张尺寸生成 PDF(与 @page 一致,零边距); /// 2) 读取 PDF → Base64; /// 3) 通过 PrintDot 桥接器发送至本地物理打印机。 /// 与后端 web 端 printNativeSchemaViaPrintDot 的行为基本一致,只是 HTML→PDF 改用 WebView2 内置能力, /// 比 html2canvas + jsPDF 更接近浏览器原生打印效果。 /// private async void Print_Click(object sender, RoutedEventArgs e) { if (_printDotService == null) { HandyControl.Controls.Growl.Warning("PrintDot 服务不可用"); return; } if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}") { HandyControl.Controls.Growl.Warning("当前模板没有可打印内容"); return; } var selected = PrinterCombo.SelectedItem as PrintDotPrinter; if (selected == null || string.IsNullOrWhiteSpace(selected.Name)) { HandyControl.Controls.Growl.Warning("请先选择打印机"); return; } BtnPrint.IsEnabled = false; var pdfPath = Path.Combine(Path.GetTempPath(), $"qhmes_print_{Guid.NewGuid():N}.pdf"); try { // 1) 生成 PDF SetStatus("正在生成 PDF..."); await WebView.EnsureCoreWebView2Async(); var pageW = (_template.PaperWidthMm ?? 210); var pageH = (_template.PaperHeightMm ?? 297); var settings = WebView.CoreWebView2.Environment.CreatePrintSettings(); settings.PageWidth = pageW / 25.4d; // 毫米转英寸 settings.PageHeight = pageH / 25.4d; settings.MarginTop = 0; settings.MarginBottom = 0; settings.MarginLeft = 0; settings.MarginRight = 0; settings.ShouldPrintBackgrounds = true; settings.ShouldPrintHeaderAndFooter = false; settings.Orientation = string.Equals(_template.PaperOrientation, "横向", StringComparison.Ordinal) ? Microsoft.Web.WebView2.Core.CoreWebView2PrintOrientation.Landscape : Microsoft.Web.WebView2.Core.CoreWebView2PrintOrientation.Portrait; var ok = await WebView.CoreWebView2.PrintToPdfAsync(pdfPath, settings); if (!ok || !File.Exists(pdfPath)) throw new InvalidOperationException("生成 PDF 失败,请确认预览已加载完成"); // 2) 读取为 Base64 var pdfBytes = await File.ReadAllBytesAsync(pdfPath); var pdfBase64 = Convert.ToBase64String(pdfBytes); // 3) 通过 PrintDot 桥接器发送 SetStatus($"正在通过 PrintDot 发送到「{selected.Name}」..."); var jobName = string.IsNullOrWhiteSpace(_template.TemplateName) ? "QH-MES" : _template.TemplateName!; await _printDotService.PrintAsync(selected.Name, pdfBase64, jobName, copies: 1); SetStatus("打印任务已发送"); HandyControl.Controls.Growl.Success("打印任务已发送至 PrintDot"); } catch (Exception ex) { // 顶部状态栏单行显示首行,完整多行处理步骤在 Growl 弹窗中展示 SetStatus($"打印失败:{ex.Message}"); HandyControl.Controls.Growl.Error($"打印失败:{ex.Message}"); } finally { try { if (File.Exists(pdfPath)) File.Delete(pdfPath); } catch { /* 忽略清理失败 */ } BtnPrint.IsEnabled = true; } } }