2026-05-13 12:35:02 +08:00
|
|
|
|
using System.Globalization;
|
|
|
|
|
|
using System.IO;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
using System.Text.Json;
|
|
|
|
|
|
using System.Text.Json.Nodes;
|
|
|
|
|
|
using System.Text.RegularExpressions;
|
|
|
|
|
|
using System.Windows;
|
|
|
|
|
|
using YY.Admin.Core.Entity;
|
2026-05-13 12:35:02 +08:00
|
|
|
|
using YY.Admin.Core.Services;
|
2026-05-15 17:30:30 +08:00
|
|
|
|
using YY.Admin.Helper;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
using YY.Admin.Services.Service.Print;
|
|
|
|
|
|
|
|
|
|
|
|
namespace YY.Admin.Views.Print;
|
|
|
|
|
|
|
|
|
|
|
|
public partial class PrintPreviewWindow : HandyControl.Controls.Window
|
|
|
|
|
|
{
|
|
|
|
|
|
private readonly string _templateJson;
|
2026-05-13 12:35:02 +08:00
|
|
|
|
private readonly PrintTemplate _template;
|
|
|
|
|
|
private readonly IPrintDotService? _printDotService;
|
|
|
|
|
|
private readonly string? _initialPrinterName;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
public PrintPreviewWindow(PrintTemplate template, string? templateJson)
|
2026-05-13 12:35:02 +08:00
|
|
|
|
: this(template, templateJson, null, null)
|
|
|
|
|
|
{
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
public PrintPreviewWindow(PrintTemplate template, string? templateJson,
|
2026-05-13 15:49:51 +08:00
|
|
|
|
IPrintDotService? printDotService, string? selectedPrinterName,
|
|
|
|
|
|
string? initialParamJson = null)
|
2026-05-12 18:29:03 +08:00
|
|
|
|
{
|
|
|
|
|
|
InitializeComponent();
|
2026-05-13 12:35:02 +08:00
|
|
|
|
_template = template;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
_templateJson = templateJson ?? string.Empty;
|
2026-05-13 12:35:02 +08:00
|
|
|
|
_printDotService = printDotService;
|
|
|
|
|
|
_initialPrinterName = selectedPrinterName;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
2026-05-15 17:30:30 +08:00
|
|
|
|
WebView.CreationProperties = WebView2UserDataFolder.CreateCreationProperties("PrintPreview");
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
TbTemplateName.Text = template.TemplateName ?? "(未命名)";
|
|
|
|
|
|
TbTemplateCode.Text = $"编码:{template.TemplateCode} " +
|
|
|
|
|
|
$"尺寸:{template.PaperWidthMm ?? 210}×{template.PaperHeightMm ?? 297} mm " +
|
|
|
|
|
|
$"方向:{template.PaperOrientation ?? "纵向"}";
|
|
|
|
|
|
|
2026-05-13 15:49:51 +08:00
|
|
|
|
TbParamJson.Text = !string.IsNullOrWhiteSpace(initialParamJson)
|
|
|
|
|
|
? initialParamJson!
|
|
|
|
|
|
: BuildMockParamJson(_templateJson);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
2026-05-13 12:35:02 +08:00
|
|
|
|
// 没有 PrintDot 服务时禁用打印相关按钮
|
|
|
|
|
|
if (_printDotService == null)
|
|
|
|
|
|
{
|
|
|
|
|
|
BtnPrint.IsEnabled = false;
|
|
|
|
|
|
BtnRefreshPrinters.IsEnabled = false;
|
|
|
|
|
|
PrinterCombo.IsEnabled = false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Loaded += async (_, _) =>
|
|
|
|
|
|
{
|
|
|
|
|
|
await LoadPreviewAsync();
|
|
|
|
|
|
await LoadPrintersAsync(verbose: false);
|
|
|
|
|
|
};
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private async Task LoadPreviewAsync()
|
|
|
|
|
|
{
|
|
|
|
|
|
try
|
|
|
|
|
|
{
|
2026-05-13 12:35:02 +08:00
|
|
|
|
SetStatus("加载中…");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
await WebView.EnsureCoreWebView2Async();
|
|
|
|
|
|
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}")
|
|
|
|
|
|
{
|
|
|
|
|
|
WebView.NavigateToString(BuildEmptyHtml());
|
2026-05-13 12:35:02 +08:00
|
|
|
|
SetStatus("尚未设计模板内容");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await RenderCurrentParamJsonAsync();
|
|
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
2026-05-13 12:35:02 +08:00
|
|
|
|
SetStatus($"预览失败:{ex.Message}");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
{
|
2026-05-13 12:35:02 +08:00
|
|
|
|
SetStatus("渲染中…");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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)"));
|
2026-05-13 12:35:02 +08:00
|
|
|
|
SetStatus("参数JSON格式错误");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
dataObj = obj;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var html = NativePrintRenderService.RenderToHtml(_templateJson, dataObj);
|
|
|
|
|
|
WebView.NavigateToString(html);
|
2026-05-13 12:35:02 +08:00
|
|
|
|
SetStatus(string.Empty);
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
catch (Exception ex)
|
|
|
|
|
|
{
|
|
|
|
|
|
WebView.NavigateToString(BuildErrorHtml(ex.Message));
|
2026-05-13 12:35:02 +08:00
|
|
|
|
SetStatus($"渲染失败:{ex.Message}");
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
await Task.CompletedTask;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
|
/// 根据模板绑定字段生成参数 JSON(便于用户直接编辑并预览)。
|
2026-05-12 18:55:12 +08:00
|
|
|
|
/// 与 web 端 nativeMockData.ts 保持一致:识别 mergeColumnKeys 让同组相邻行字段值相同,
|
|
|
|
|
|
/// 以便在预览中触发 rowSpan 合并显示。
|
2026-05-12 18:29:03 +08:00
|
|
|
|
/// </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);
|
2026-05-12 18:55:12 +08:00
|
|
|
|
var rng = new Random();
|
2026-05-12 18:29:03 +08:00
|
|
|
|
|
|
|
|
|
|
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();
|
2026-05-12 18:55:12 +08:00
|
|
|
|
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++)
|
2026-05-12 18:29:03 +08:00
|
|
|
|
{
|
|
|
|
|
|
var row = new JsonObject();
|
2026-05-12 18:55:12 +08:00
|
|
|
|
foreach (var col in colList)
|
2026-05-12 18:29:03 +08:00
|
|
|
|
{
|
|
|
|
|
|
var field = (col["bindField"]?.ToString() ?? col["field"]?.ToString() ?? string.Empty).Trim();
|
|
|
|
|
|
if (string.IsNullOrWhiteSpace(field)) continue;
|
|
|
|
|
|
var contentType = (col["contentType"]?.ToString() ?? "text").Trim().ToLowerInvariant();
|
2026-05-12 18:55:12 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
row[field] = contentType switch
|
|
|
|
|
|
{
|
2026-05-12 18:55:12 +08:00
|
|
|
|
"number" => (i + 1) * 123.45,
|
|
|
|
|
|
"amount" => (i + 1) * 24567.89,
|
2026-05-13 12:35:02 +08:00
|
|
|
|
"qrcode" => BuildQrcodeMockValue(field, rng),
|
|
|
|
|
|
"barcode" => BuildBarcodeMockValue(field, rng),
|
2026-05-12 18:55:12 +08:00
|
|
|
|
"image" => $"https://picsum.photos/seed/{Uri.EscapeDataString(field + "_" + (i + 1))}/260/120",
|
|
|
|
|
|
_ => $"{field}_示例值_{i + 1}"
|
2026-05-12 18:29:03 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
rows.Add(row);
|
2026-05-12 18:55:12 +08:00
|
|
|
|
prevRow = row;
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|
|
|
|
|
|
obj[source] = rows;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var bind = (el["bindField"]?.ToString() ?? string.Empty).Trim();
|
|
|
|
|
|
if (!string.IsNullOrWhiteSpace(bind))
|
|
|
|
|
|
fields.Add(bind);
|
|
|
|
|
|
|
2026-05-13 12:35:02 +08:00
|
|
|
|
// 针对单一元素按 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);
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
// 提取 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 "{}";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 12:35:02 +08:00
|
|
|
|
/// <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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-12 18:29:03 +08:00
|
|
|
|
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("&", "&").Replace("<", "<").Replace(">", ">");
|
|
|
|
|
|
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();
|
2026-05-13 12:35:02 +08:00
|
|
|
|
|
|
|
|
|
|
/// <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.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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-12 18:29:03 +08:00
|
|
|
|
}
|