Files
qhmes/yy-admin-master/YY.Admin/Views/Print/PrintPreviewWindow.xaml.cs

539 lines
23 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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() => """
<!DOCTYPE html>
<html><head><meta charset="utf-8"/>
<style>
body { margin:0; background:#525659; display:flex;
align-items:center; justify-content:center; height:100vh;
font-family:"Microsoft YaHei",Arial,sans-serif; }
.card { background:#fff; border-radius:8px; padding:48px 64px;
text-align:center; box-shadow:0 6px 24px rgba(0,0,0,.4); }
.icon { font-size:48px; color:#ccc; margin-bottom:16px; }
.tip { font-size:15px; color:#888; }
</style></head>
<body>
<div class="card">
<div class="icon">📄</div>
<div class="tip"></div>
</div>
</body></html>
""";
/// <summary>
/// 左侧参数 JSON 重新渲染预览(与后端预览一致:用参数 JSON 驱动模板绑定字段)。
/// </summary>
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;
}
/// <summary>
/// 根据模板绑定字段生成参数 JSON便于用户直接编辑并预览
/// 与 web 端 nativeMockData.ts 保持一致:识别 mergeColumnKeys 让同组相邻行字段值相同,
/// 以便在预览中触发 rowSpan 合并显示。
/// </summary>
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<string>(StringComparer.OrdinalIgnoreCase);
var rng = new Random();
foreach (var el in elements.OfType<JsonObject>())
{
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<JsonObject>().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 "{}";
}
}
/// <summary>
/// 生成一个 ASCII 安全、Code128 可编码的条码 mock 值BAR + 12 位数字 + 字段名前 6 位转大写字母。
/// 与 web 端 nativeMockData.ts::buildBarcodeValue 同款规则。
/// </summary>
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}";
}
/// <summary>生成 QR mock 值:纯 ASCII便于 QRCoder 编码与人眼区分字段。</summary>
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)}";
}
/// <summary>
/// 递归扫描 cells 节点freeTable、嵌套结构按每个 cell 的 contentType 提前预填合法 mock 值。
/// 不识别的 cell 继续走原来的 fields 兜底流程。
/// </summary>
private static void CollectCellsMock(JsonNode? node, JsonObject obj, ISet<string> 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<string> 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("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
return "<html><head><meta charset=\"utf-8\"/><style>"
+ "body{margin:0;background:#525659;display:flex;align-items:center;"
+ "justify-content:center;height:100vh;font-family:'Microsoft YaHei',Arial,sans-serif;}"
+ ".card{background:#fff;border-radius:8px;padding:32px 48px;text-align:center;"
+ "box-shadow:0 6px 24px rgba(0,0,0,.4);max-width:560px;}"
+ "</style></head><body><div class=\"card\">"
+ "<div style=\"font-size:40px;margin-bottom:12px\">⚠️</div>"
+ "<div style=\"font-size:13px;color:#e74c3c;word-break:break-all;\">渲染失败:" + esc + "</div>"
+ "</div></body></html>";
}
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();
/// <summary>
/// 设置顶部状态栏文字:单行显示首行概要,完整多行内容通过 ToolTip 兜底,避免把工具栏撑高。
/// </summary>
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 桥接器) ────────────────────────────────
/// <summary>
/// 通过 PrintDot 桥接器拉取打印机列表,填充顶部下拉框,并按上次选择/系统默认/首台优先选中。
/// </summary>
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);
}
/// <summary>
/// 打印流程:
/// 1) 用 WebView2.PrintToPdfAsync 按模板纸张尺寸生成 PDF与 @page 一致,零边距);
/// 2) 读取 PDF → Base64
/// 3) 通过 PrintDot 桥接器发送至本地物理打印机。
/// 与后端 web 端 printNativeSchemaViaPrintDot 的行为基本一致,只是 HTML→PDF 改用 WebView2 内置能力,
/// 比 html2canvas + jsPDF 更接近浏览器原生打印效果。
/// </summary>
private async void Print_Click(object sender, RoutedEventArgs e)
{
if (_printDotService == null)
{
HandyControl.Controls.MessageBox.Warning("PrintDot 服务不可用");
return;
}
if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}")
{
HandyControl.Controls.MessageBox.Warning("当前模板没有可打印内容");
return;
}
var selected = PrinterCombo.SelectedItem as PrintDotPrinter;
if (selected == null || string.IsNullOrWhiteSpace(selected.Name))
{
HandyControl.Controls.MessageBox.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.MessageBox.Success("打印任务已发送至 PrintDot");
}
catch (Exception ex)
{
// 独立预览窗无 Growl 面板,用状态栏 + MessageBox 展示错误,避免 NullReferenceException
SetStatus($"打印失败:{ex.Message}");
HandyControl.Controls.MessageBox.Error($"打印失败:{ex.Message}");
}
finally
{
try { if (File.Exists(pdfPath)) File.Delete(pdfPath); } catch { /* 忽略清理失败 */ }
BtnPrint.IsEnabled = true;
}
}
}