增强条码元素和自由表格元素的渲染逻辑,支持更多条码格式和文本边框样式。新增条码渲染工具,优化打印预览窗口的打印机选择功能,提升用户体验和打印模板的灵活性。

This commit is contained in:
geht
2026-05-13 12:35:02 +08:00
parent d2f49add82
commit 2c8620522b
15 changed files with 1446 additions and 125 deletions

View File

@@ -14,6 +14,7 @@ namespace YY.Admin.ViewModels.Print;
public class PrintTemplateListViewModel : BaseViewModel
{
private readonly IPrintTemplateService _printTemplateService;
private readonly IPrintDotService _printDotService;
private readonly IEventAggregator _eventAggregator;
private SubscriptionToken? _changeToken;
@@ -21,6 +22,32 @@ public class PrintTemplateListViewModel : BaseViewModel
public ObservableCollection<PrintTemplate> Templates { get; } = new();
// ── PrintDot 打印机选择 ────────────────────────────────────────────────
public ObservableCollection<PrintDotPrinter> Printers { get; } = new();
private bool _suppressPrinterSave;
private PrintDotPrinter? _selectedPrinter;
public PrintDotPrinter? SelectedPrinter
{
get => _selectedPrinter;
set
{
if (!SetProperty(ref _selectedPrinter, value)) return;
if (_suppressPrinterSave) return;
// 持久化用户选择,预览窗口和后续会话使用
var s = PrintDotSettings.Load();
s.SelectedPrinter = value?.Name ?? string.Empty;
s.Save();
}
}
private string _printerStatus = string.Empty;
public string PrinterStatus
{
get => _printerStatus;
set => SetProperty(ref _printerStatus, value);
}
private string _statusMessage = string.Empty;
public string StatusMessage
{
@@ -52,14 +79,17 @@ public class PrintTemplateListViewModel : BaseViewModel
public DelegateCommand SearchCommand { get; }
public DelegateCommand ResetCommand { get; }
public DelegateCommand<PrintTemplate> PreviewCommand { get; }
public DelegateCommand RefreshPrintersCommand { get; }
public PrintTemplateListViewModel(
IPrintTemplateService printTemplateService,
IPrintDotService printDotService,
IEventAggregator eventAggregator,
IContainerExtension container,
IRegionManager regionManager) : base(container, regionManager)
{
_printTemplateService = printTemplateService;
_printDotService = printDotService;
_eventAggregator = eventAggregator;
SearchCommand = new DelegateCommand(ApplyFilter);
@@ -71,6 +101,7 @@ public class PrintTemplateListViewModel : BaseViewModel
FilterCategory = null;
ApplyFilter();
});
RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true));
_changeToken = _eventAggregator
.GetEvent<PrintTemplateChangedEvent>()
@@ -79,6 +110,51 @@ public class PrintTemplateListViewModel : BaseViewModel
// 先用缓存立即填充,再后台静默刷新
ShowCached();
_ = RefreshSilentlyAsync();
// 后台静默连接 PrintDot 桥接器,初次加载打印机
_ = RefreshPrintersAsync(verbose: false);
}
/// <summary>
/// 通过 PrintDot 桥接器拉取打印机列表,与后端 web 列表页的 fetchPrintDotPrinters 行为对齐。
/// </summary>
private async Task RefreshPrintersAsync(bool verbose)
{
if (verbose) PrinterStatus = "刷新打印机中...";
var savedName = PrintDotSettings.Load().SelectedPrinter;
try
{
var list = await _printDotService.GetPrintersAsync();
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
Printers.Clear();
foreach (var p in list) Printers.Add(p);
// 选中规则:上次保存 > 系统默认 > 首台
var match = list.FirstOrDefault(p => p.Name == savedName)
?? list.FirstOrDefault(p => p.IsDefault)
?? (list.Count > 0 ? list[0] : null);
_suppressPrinterSave = true;
SelectedPrinter = match;
_suppressPrinterSave = false;
PrinterStatus = list.Count > 0 ? $"共 {list.Count} 台打印机" : "未检测到打印机";
});
}
catch (Exception ex)
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
{
Printers.Clear();
_suppressPrinterSave = true;
SelectedPrinter = null;
_suppressPrinterSave = false;
PrinterStatus = verbose
? $"PrintDot 未连接:{ex.Message}"
: "PrintDot 未连接";
});
}
}
private void ShowCached()
@@ -192,7 +268,7 @@ public class PrintTemplateListViewModel : BaseViewModel
catch { /* 保持 json 为 null预览窗口显示"尚未设计" */ }
}
var win = new PrintPreviewWindow(template, json)
var win = new PrintPreviewWindow(template, json, _printDotService, SelectedPrinter?.Name)
{
Owner = Application.Current.MainWindow
};

View File

@@ -44,9 +44,29 @@
VerticalAlignment="Center">
<TextBlock x:Name="TbStatus"
FontSize="12" Foreground="#888888"
VerticalAlignment="Center" Margin="0,0,16,0"/>
VerticalAlignment="Center" Margin="0,0,12,0"
MaxWidth="320"
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis"
ToolTipService.ShowDuration="30000"/>
<TextBlock Text="打印机:" VerticalAlignment="Center"
FontSize="12" Foreground="#333333"/>
<ComboBox x:Name="PrinterCombo"
MinWidth="200" Height="30"
VerticalContentAlignment="Center"
DisplayMemberPath="Name"/>
<Button x:Name="BtnRefreshPrinters"
Content="刷新打印机"
Click="RefreshPrinters_Click"
Margin="6,0,0,0" Height="30" Padding="10,0" FontSize="12"
Style="{StaticResource ButtonDefault}"/>
<Button x:Name="BtnPrint"
Content="打印"
Click="Print_Click"
Margin="12,0,0,0" Width="84" Height="30" FontSize="13"
Style="{StaticResource ButtonPrimary}"/>
<Button Content="关闭" Click="CloseButton_Click"
Width="72" Height="30" FontSize="13"
Margin="8,0,0,0" Width="72" Height="30" FontSize="13"
Style="{StaticResource ButtonDefault}"/>
</StackPanel>
</Grid>

View File

@@ -1,8 +1,11 @@
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.Services.Service.Print;
namespace YY.Admin.Views.Print;
@@ -10,11 +13,23 @@ 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)
{
InitializeComponent();
_template = template;
_templateJson = templateJson ?? string.Empty;
_printDotService = printDotService;
_initialPrinterName = selectedPrinterName;
TbTemplateName.Text = template.TemplateName ?? "(未命名)";
TbTemplateCode.Text = $"编码:{template.TemplateCode} " +
@@ -23,20 +38,32 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
TbParamJson.Text = BuildMockParamJson(_templateJson);
Loaded += async (_, _) => await LoadPreviewAsync();
// 没有 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
{
TbStatus.Text = "加载中…";
SetStatus("加载中…");
await WebView.EnsureCoreWebView2Async();
if (string.IsNullOrWhiteSpace(_templateJson) || _templateJson == "{}")
{
WebView.NavigateToString(BuildEmptyHtml());
TbStatus.Text = "尚未设计模板内容";
SetStatus("尚未设计模板内容");
return;
}
@@ -44,7 +71,7 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
}
catch (Exception ex)
{
TbStatus.Text = $"预览失败:{ex.Message}";
SetStatus($"预览失败:{ex.Message}");
}
}
@@ -75,7 +102,7 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
{
try
{
TbStatus.Text = "渲染中…";
SetStatus("渲染中…");
JsonObject dataObj;
var text = TbParamJson.Text?.Trim();
@@ -89,7 +116,7 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
if (node is not JsonObject obj)
{
WebView.NavigateToString(BuildErrorHtml("参数JSON必须是对象JSON Object"));
TbStatus.Text = "参数JSON格式错误";
SetStatus("参数JSON格式错误");
return;
}
dataObj = obj;
@@ -97,12 +124,12 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
var html = NativePrintRenderService.RenderToHtml(_templateJson, dataObj);
WebView.NavigateToString(html);
TbStatus.Text = string.Empty;
SetStatus(string.Empty);
}
catch (Exception ex)
{
WebView.NavigateToString(BuildErrorHtml(ex.Message));
TbStatus.Text = $"渲染失败:{ex.Message}";
SetStatus($"渲染失败:{ex.Message}");
}
await Task.CompletedTask;
}
@@ -192,8 +219,8 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
{
"number" => (i + 1) * 123.45,
"amount" => (i + 1) * 24567.89,
"qrcode" => $"QR_{field}_{i + 1}",
"barcode" => $"BAR_{field}_{i + 1}",
"qrcode" => BuildQrcodeMockValue(field, rng),
"barcode" => BuildBarcodeMockValue(field, rng),
"image" => $"https://picsum.photos/seed/{Uri.EscapeDataString(field + "_" + (i + 1))}/260/120",
_ => $"{field}_示例值_{i + 1}"
};
@@ -209,6 +236,30 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
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*\}\}"))
@@ -217,8 +268,6 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
if (!string.IsNullOrWhiteSpace(key))
fields.Add(key);
}
CollectBindFields(el["cells"], fields);
}
foreach (var f in fields.OrderBy(x => x, StringComparer.OrdinalIgnoreCase))
@@ -237,6 +286,71 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
}
}
/// <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;
@@ -285,4 +399,134 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
}
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.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;
}
}
}

View File

@@ -52,20 +52,54 @@
<!-- 操作工具栏 -->
<Border Grid.Row="1" Margin="0,10">
<hc:UniformSpacingPanel Spacing="10">
<Button Style="{StaticResource ButtonPrimary}" Command="{Binding SearchCommand}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Search"/>
<TextBlock Text="搜索" Style="{StaticResource IconButtonStyle}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource ButtonDefault}" Command="{Binding ResetCommand}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Refresh"/>
<TextBlock Text="重置" Style="{StaticResource IconButtonStyle}"/>
</StackPanel>
</Button>
</hc:UniformSpacingPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<hc:UniformSpacingPanel Grid.Column="0" Spacing="10" VerticalAlignment="Center">
<Button Style="{StaticResource ButtonPrimary}" Command="{Binding SearchCommand}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Search"/>
<TextBlock Text="搜索" Style="{StaticResource IconButtonStyle}"/>
</StackPanel>
</Button>
<Button Style="{StaticResource ButtonDefault}" Command="{Binding ResetCommand}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Refresh"/>
<TextBlock Text="重置" Style="{StaticResource IconButtonStyle}"/>
</StackPanel>
</Button>
</hc:UniformSpacingPanel>
<!-- PrintDot 打印机选择(与后端列表页对齐) -->
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="打印机:" VerticalAlignment="Center"
FontSize="13" Foreground="#333333"/>
<ComboBox ItemsSource="{Binding Printers}"
SelectedItem="{Binding SelectedPrinter}"
DisplayMemberPath="Name"
MinWidth="220" Height="30"
VerticalContentAlignment="Center"
hc:InfoElement.Placeholder="请选择打印机"/>
<Button Command="{Binding RefreshPrintersCommand}"
Height="30" Padding="10,0" Margin="8,0,0,0"
FontSize="12"
Style="{StaticResource ButtonDefault}">
<StackPanel Orientation="Horizontal">
<md:PackIcon Kind="Refresh" VerticalAlignment="Center"/>
<TextBlock Text="刷新打印机" Margin="4,0,0,0" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<TextBlock Text="{Binding PrinterStatus}"
Margin="12,0,0,0"
VerticalAlignment="Center"
FontSize="12"
Foreground="{DynamicResource SecondaryTextBrush}"/>
</StackPanel>
</Grid>
</Border>
<!-- 数据表格 -->