增强条码元素和自由表格元素的渲染逻辑,支持更多条码格式和文本边框样式。新增条码渲染工具,优化打印预览窗口的打印机选择功能,提升用户体验和打印模板的灵活性。
This commit is contained in:
@@ -4,6 +4,8 @@ using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using QRCoder;
|
||||
using ZXing;
|
||||
using ZXing.Rendering;
|
||||
|
||||
namespace YY.Admin.Services.Service.Print;
|
||||
|
||||
@@ -312,6 +314,11 @@ public static class NativePrintRenderService
|
||||
|
||||
// ─── Barcode ────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 使用 ZXing.Net 的 SVG 渲染器生成真实可扫码的 1D 条码,与 web 端 jsbarcode 输出的
|
||||
/// SVG 行为一致(preserveAspectRatio="xMidYMid meet",外层 CSS 100% 保比例铺满)。
|
||||
/// 失败时回退到原占位 SVG,避免渲染流程中断。
|
||||
/// </summary>
|
||||
private static string RenderBarcode(JsonNode el, JsonObject data, string posStyle, double w, double h,
|
||||
bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1)
|
||||
{
|
||||
@@ -321,12 +328,14 @@ public static class NativePrintRenderService
|
||||
value = ResolveField(data, bindField)?.ToString() ?? value;
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
|
||||
var escapedVal = EscapeHtml(value);
|
||||
var inner = $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"75%\">" +
|
||||
$"<rect width=\"100%\" height=\"100%\" fill=\"white\"/>" +
|
||||
$"<text x=\"50%\" y=\"50%\" font-size=\"8\" text-anchor=\"middle\" dominant-baseline=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
|
||||
$"</svg><span style=\"font-size:9px;font-family:monospace;\">{escapedVal}</span>";
|
||||
var wrapStyle = "display:flex;flex-direction:column;align-items:center;justify-content:center;";
|
||||
// 从元素配置取格式/显示文字开关;元素未设时按 Code128 + 显示文字默认
|
||||
var format = ParseBarcodeFormat(ReadAsString(el["format"]));
|
||||
var displayValue = !string.Equals(ReadAsString(el["displayValue"], "true"), "false", StringComparison.OrdinalIgnoreCase);
|
||||
var textAlign = ReadAsString(el["textAlign"], "center")!;
|
||||
|
||||
var inner = BuildBarcodeSvgInner(value, format, displayValue, textAlign);
|
||||
|
||||
var wrapStyle = "display:flex;align-items:center;justify-content:center;overflow:hidden;";
|
||||
|
||||
if (!bandRepeat || totalPages <= 1)
|
||||
return $"<div class=\"el\" style=\"{posStyle}{wrapStyle}\">{inner}</div>\n";
|
||||
@@ -341,6 +350,230 @@ public static class NativePrintRenderService
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把模板元素上的 format 字符串映射到 ZXing 的 BarcodeFormat 枚举。
|
||||
/// 覆盖 web 端设计器(jsbarcode)支持的全部码制;对 ZXing 不直接支持的子集
|
||||
/// (CODE128 子集、MSI 变体、EAN-5/EAN-2、pharmacode)做最接近映射,避免渲染失败。
|
||||
/// </summary>
|
||||
private static BarcodeFormat ParseBarcodeFormat(string? fmt)
|
||||
{
|
||||
var key = (fmt ?? string.Empty).Trim().ToUpperInvariant().Replace("-", "_");
|
||||
return key switch
|
||||
{
|
||||
// Code128 系列:ZXing 自动识别 A/B/C 编码集,统一映射到 CODE_128
|
||||
"" => BarcodeFormat.CODE_128,
|
||||
"CODE128" => BarcodeFormat.CODE_128,
|
||||
"CODE_128" => BarcodeFormat.CODE_128,
|
||||
"CODE128A" => BarcodeFormat.CODE_128,
|
||||
"CODE128B" => BarcodeFormat.CODE_128,
|
||||
"CODE128C" => BarcodeFormat.CODE_128,
|
||||
|
||||
// Code 39 / 93
|
||||
"CODE39" => BarcodeFormat.CODE_39,
|
||||
"CODE_39" => BarcodeFormat.CODE_39,
|
||||
"CODE93" => BarcodeFormat.CODE_93,
|
||||
"CODE_93" => BarcodeFormat.CODE_93,
|
||||
|
||||
// EAN / UPC
|
||||
"EAN13" => BarcodeFormat.EAN_13,
|
||||
"EAN_13" => BarcodeFormat.EAN_13,
|
||||
"EAN8" => BarcodeFormat.EAN_8,
|
||||
"EAN_8" => BarcodeFormat.EAN_8,
|
||||
// EAN-5 / EAN-2 是 EAN-13 的附加码,ZXing 不直接支持;为避免渲染失败回退为 CODE_128
|
||||
"EAN5" => BarcodeFormat.CODE_128,
|
||||
"EAN_5" => BarcodeFormat.CODE_128,
|
||||
"EAN2" => BarcodeFormat.CODE_128,
|
||||
"EAN_2" => BarcodeFormat.CODE_128,
|
||||
"UPC" => BarcodeFormat.UPC_A,
|
||||
"UPCA" => BarcodeFormat.UPC_A,
|
||||
"UPC_A" => BarcodeFormat.UPC_A,
|
||||
"UPCE" => BarcodeFormat.UPC_E,
|
||||
"UPC_E" => BarcodeFormat.UPC_E,
|
||||
|
||||
// ITF / ITF-14
|
||||
"ITF" => BarcodeFormat.ITF,
|
||||
"ITF14" => BarcodeFormat.ITF,
|
||||
"ITF_14" => BarcodeFormat.ITF,
|
||||
|
||||
// MSI 全部变体:ZXing 不直接支持,回退到 CODE_128(保证至少能扫码识别值)
|
||||
"MSI" => BarcodeFormat.CODE_128,
|
||||
"MSI10" => BarcodeFormat.CODE_128,
|
||||
"MSI11" => BarcodeFormat.CODE_128,
|
||||
"MSI1010" => BarcodeFormat.CODE_128,
|
||||
"MSI1110" => BarcodeFormat.CODE_128,
|
||||
|
||||
// Pharmacode:ZXing 不支持,回退 CODE_128
|
||||
"PHARMACODE" => BarcodeFormat.CODE_128,
|
||||
|
||||
// Codabar
|
||||
"CODABAR" => BarcodeFormat.CODABAR,
|
||||
|
||||
_ => BarcodeFormat.CODE_128,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把 ZXing 输出的 SVG 转换为"由外层 CSS 控制尺寸 + 保持原始条宽比例"的形式:
|
||||
/// 1) 若原 SVG 无 viewBox,用原 width/height 派生(让 preserveAspectRatio 生效);
|
||||
/// 2) 删除根 svg 节点的 width/height 属性(移交给 CSS);
|
||||
/// 3) 强制 preserveAspectRatio="xMidYMid meet"(与 web 端 jsbarcode 一致);
|
||||
/// 4) 重新挂上 width="100%" height="100%",确保 div 100% 铺满。
|
||||
/// 防护层:若 ZXing 输出的 SVG 已带 viewBox,直接复用;否则从 width/height 推导。
|
||||
/// </summary>
|
||||
private static string? NormalizeBarcodeSvg(string svg)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(svg)) return null;
|
||||
return Regex.Replace(svg, @"<svg\b([^>]*)>", m =>
|
||||
{
|
||||
var attrs = m.Groups[1].Value;
|
||||
|
||||
// 取原 width/height 数值(若有),用于派生 viewBox
|
||||
var widthMatch = Regex.Match(attrs, @"\swidth\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
|
||||
var heightMatch = Regex.Match(attrs, @"\sheight\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
|
||||
var hasViewBox = Regex.IsMatch(attrs, @"\sviewBox\s*=", RegexOptions.IgnoreCase);
|
||||
|
||||
if (!hasViewBox && widthMatch.Success && heightMatch.Success
|
||||
&& double.TryParse(Regex.Replace(widthMatch.Groups[1].Value, @"[^\d.]", ""),
|
||||
NumberStyles.Any, CultureInfo.InvariantCulture, out var w) && w > 0
|
||||
&& double.TryParse(Regex.Replace(heightMatch.Groups[1].Value, @"[^\d.]", ""),
|
||||
NumberStyles.Any, CultureInfo.InvariantCulture, out var h) && h > 0)
|
||||
{
|
||||
attrs += $" viewBox=\"0 0 {w.ToString("0.###", CultureInfo.InvariantCulture)} {h.ToString("0.###", CultureInfo.InvariantCulture)}\"";
|
||||
}
|
||||
|
||||
attrs = Regex.Replace(attrs, @"\s(width|height)\s*=\s*""[^""]*""", string.Empty, RegexOptions.IgnoreCase);
|
||||
if (!Regex.IsMatch(attrs, @"preserveAspectRatio", RegexOptions.IgnoreCase))
|
||||
attrs += " preserveAspectRatio=\"xMidYMid meet\"";
|
||||
return $"<svg width=\"100%\" height=\"100%\"{attrs}>";
|
||||
}, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>条码生成失败时的占位 SVG,至少把原文字显示出来便于排查。</summary>
|
||||
private static string BuildFallbackBarcodeSvg(string value)
|
||||
{
|
||||
var escapedVal = EscapeHtml(value);
|
||||
return $"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"100%\" height=\"100%\" preserveAspectRatio=\"xMidYMid meet\" viewBox=\"0 0 200 60\">" +
|
||||
$"<rect width=\"200\" height=\"60\" fill=\"white\"/>" +
|
||||
$"<text x=\"100\" y=\"35\" font-size=\"12\" text-anchor=\"middle\" font-family=\"monospace\">{escapedVal}</text>" +
|
||||
$"</svg>";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 表格单元格用:包装统一的 BuildBarcodeSvgInner,保持与独立 barcode 元素一致的视觉比例。
|
||||
/// </summary>
|
||||
private static string BuildBarcodeCellSvg(string value, string? format = null, bool displayValue = true, string? textAlign = null)
|
||||
=> BuildBarcodeSvgInner(value ?? string.Empty, ParseBarcodeFormat(format), displayValue, textAlign);
|
||||
|
||||
/// <summary>
|
||||
/// 调用 ZXing.Net 生成条码 SVG 的统一入口:用与 web 端 jsbarcode 默认参数等价的比例
|
||||
/// (lineWidth ≈ 2px/module、barHeight ≈ 60px、底部文字区 ≈ 18px)来派生 ZXing 的
|
||||
/// Width/Height,使生成的 SVG 内部 viewBox 比例稳定(与 web 视觉风格对齐),不会随
|
||||
/// 元素框形状(90×60 / 50×30 mm)漂移成 1.5:1。外层 div 用 100%+preserveAspectRatio
|
||||
/// ="xMidYMid meet" 自适应铺满。
|
||||
/// textAlign 控制底部文字对齐:center / left / right / justify(两端),通过 SVG
|
||||
/// 后处理修改 ZXing 输出的 <text> 节点 x/text-anchor 实现,与 web 端逻辑同源。
|
||||
/// </summary>
|
||||
private static string BuildBarcodeSvgInner(string value, BarcodeFormat format, bool displayValue, string? textAlign = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
|
||||
try
|
||||
{
|
||||
// moduleCount 仅用于派生稳定的 SVG viewBox 宽度,ZXing 实际按编码后的真实模块数
|
||||
// 1:1 绘制;多给一点宽度不会让条变粗,只会让两侧留白略多。
|
||||
var moduleCount = EstimateBarcodeModuleCount(value, format);
|
||||
const int lineWidth = 2;
|
||||
const int barHeight = 60;
|
||||
var fontFooter = displayValue ? 18 : 0; // jsbarcode 默认 fontSize 14 + 上下 padding ≈ 18px
|
||||
var widthPx = Math.Max(120, moduleCount * lineWidth);
|
||||
var heightPx = barHeight + fontFooter;
|
||||
|
||||
var writer = new BarcodeWriterSvg
|
||||
{
|
||||
Format = format,
|
||||
Options = new ZXing.Common.EncodingOptions
|
||||
{
|
||||
Width = widthPx,
|
||||
Height = heightPx,
|
||||
Margin = 0,
|
||||
PureBarcode = !displayValue,
|
||||
},
|
||||
};
|
||||
var svgStr = writer.Write(value)?.Content ?? string.Empty;
|
||||
var normalized = NormalizeBarcodeSvg(svgStr) ?? BuildFallbackBarcodeSvg(value);
|
||||
// 仅当显示文字时执行对齐后处理
|
||||
if (displayValue) normalized = ApplyBarcodeTextAlign(normalized, textAlign);
|
||||
return normalized;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BuildFallbackBarcodeSvg(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 后处理 ZXing 输出 SVG 中的底部文字 <text> 节点,按 textAlign 修改 x/text-anchor,
|
||||
/// justify(两端对齐)通过 textLength + lengthAdjust=spacing 让浏览器自动拉伸字符间距。
|
||||
/// 宽度从 SVG 自身的 viewBox 第三个分量读取,不依赖 ZXing 的内部缩放策略,保证
|
||||
/// "user units 坐标系内对齐 + 浏览器渲染" 一致。
|
||||
/// </summary>
|
||||
private static string ApplyBarcodeTextAlign(string svg, string? textAlign)
|
||||
{
|
||||
if (string.IsNullOrEmpty(svg)) return svg;
|
||||
var align = (textAlign ?? "center").Trim().ToLowerInvariant();
|
||||
if (align is not ("left" or "right" or "justify")) return svg; // center 即 ZXing 默认,不动
|
||||
|
||||
// 解析 viewBox 取 user units 宽度,作为对齐坐标参照
|
||||
double vbWidth = 0;
|
||||
var vbMatch = Regex.Match(svg, @"viewBox\s*=\s*""([^""]+)""", RegexOptions.IgnoreCase);
|
||||
if (vbMatch.Success)
|
||||
{
|
||||
var parts = Regex.Split(vbMatch.Groups[1].Value.Trim(), @"[\s,]+");
|
||||
if (parts.Length >= 4
|
||||
&& double.TryParse(parts[2], NumberStyles.Any, CultureInfo.InvariantCulture, out var w) && w > 0)
|
||||
{
|
||||
vbWidth = w;
|
||||
}
|
||||
}
|
||||
if (vbWidth <= 0) return svg; // 拿不到 viewBox 宽度时不动,保持 ZXing 默认居中
|
||||
|
||||
return Regex.Replace(svg, @"<text\b([^>]*)>", m =>
|
||||
{
|
||||
var attrs = m.Groups[1].Value;
|
||||
attrs = Regex.Replace(attrs, @"\s(x|text-anchor|textLength|lengthAdjust)\s*=\s*""[^""]*""", string.Empty, RegexOptions.IgnoreCase);
|
||||
var widthStr = vbWidth.ToString("0.###", CultureInfo.InvariantCulture);
|
||||
|
||||
return align switch
|
||||
{
|
||||
"left" => $"<text x=\"0\" text-anchor=\"start\"{attrs}>",
|
||||
"right" => $"<text x=\"{widthStr}\" text-anchor=\"end\"{attrs}>",
|
||||
"justify" => $"<text x=\"0\" text-anchor=\"start\" textLength=\"{widthStr}\" lengthAdjust=\"spacing\"{attrs}>",
|
||||
_ => m.Value,
|
||||
};
|
||||
}, RegexOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 估算 1D 条码所需的模块数(即最窄条单位个数),用于给 ZXing 提供合适的 Width,
|
||||
/// 确保 SVG viewBox 比例稳定。各码制按字符 × 每字符模块数 + 起止/校验粗略估算。
|
||||
/// </summary>
|
||||
private static int EstimateBarcodeModuleCount(string value, BarcodeFormat format)
|
||||
{
|
||||
var n = Math.Max(1, value?.Length ?? 1);
|
||||
return format switch
|
||||
{
|
||||
BarcodeFormat.CODE_128 => n * 11 + 35,
|
||||
BarcodeFormat.CODE_39 => (n + 2) * 13,
|
||||
BarcodeFormat.CODE_93 => (n + 4) * 9,
|
||||
BarcodeFormat.EAN_13 => 95,
|
||||
BarcodeFormat.UPC_A => 95,
|
||||
BarcodeFormat.EAN_8 => 67,
|
||||
BarcodeFormat.UPC_E => 51,
|
||||
BarcodeFormat.ITF => (n / 2 + 1) * 9 + 7,
|
||||
BarcodeFormat.CODABAR => (n + 2) * 10,
|
||||
_ => n * 11 + 35,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Image ─────────────────────────────────────────────────────────────
|
||||
|
||||
private static string RenderImage(JsonNode el, JsonObject data, string posStyle,
|
||||
@@ -798,7 +1031,8 @@ public static class NativePrintRenderService
|
||||
{
|
||||
var ws = fillCell ? "100%" : $"{scale.ToString("0", CultureInfo.InvariantCulture)}%";
|
||||
var hs = fillCell ? "100%" : $"{Math.Max(20d, scale * 0.6d).ToString("0", CultureInfo.InvariantCulture)}%";
|
||||
return $"<div style=\"display:flex;align-items:center;justify-content:center;border:1px dashed #999;width:{ws};height:{hs};margin:0 auto;font-family:monospace;\">{EscapeHtml(innerArg)}</div>";
|
||||
var svg = BuildBarcodeCellSvg(innerArg);
|
||||
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:{ws};height:{hs};margin:0 auto;overflow:hidden;\">{svg}</div>";
|
||||
}
|
||||
return EscapeHtml(displayValue);
|
||||
}
|
||||
@@ -1350,7 +1584,10 @@ public static class NativePrintRenderService
|
||||
catch { return EscapeHtml(value); }
|
||||
}
|
||||
if (t == "barcode")
|
||||
return $"<div style=\"font-family:monospace;text-align:center;\">{EscapeHtml(value)}</div>";
|
||||
{
|
||||
var svg = BuildBarcodeCellSvg(value);
|
||||
return $"<div style=\"display:flex;align-items:center;justify-content:center;width:100%;height:100%;overflow:hidden;\">{svg}</div>";
|
||||
}
|
||||
if (t == "image")
|
||||
return $"<img src=\"{EscapeHtml(value)}\" style=\"display:block;margin:0 auto;max-width:100%;max-height:100%;object-fit:contain;\" />";
|
||||
return EscapeHtml(value);
|
||||
|
||||
@@ -90,10 +90,31 @@ public class PrintDotService : IPrintDotService
|
||||
var status = resDoc?["status"]?.GetValue<string>();
|
||||
if (status == null) continue;
|
||||
if (status == "success") return;
|
||||
throw new InvalidOperationException(resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败");
|
||||
var rawMsg = resDoc?["message"]?.GetValue<string>() ?? "PrintDot 打印失败";
|
||||
throw new InvalidOperationException(EnhanceErrorMessage(rawMsg));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将 PrintDot 返回的部分英文错误转换为带本地处理步骤的中文提示。
|
||||
/// 与 web 端 printDotBridge.ts::enhancePrintDotErrorMessage 行为一致,方便桌面端用户自助排查。
|
||||
/// </summary>
|
||||
private static string EnhanceErrorMessage(string raw)
|
||||
{
|
||||
var m = (raw ?? string.Empty).Trim();
|
||||
// 缺 SumatraPDF:是 PrintDot 客户端最常见的初始化错误
|
||||
if (System.Text.RegularExpressions.Regex.IsMatch(m, @"SumatraPDF\.exe not found", System.Text.RegularExpressions.RegexOptions.IgnoreCase)
|
||||
|| System.Text.RegularExpressions.Regex.IsMatch(m, "SUMATRAPDF_PATH", System.Text.RegularExpressions.RegexOptions.IgnoreCase))
|
||||
{
|
||||
return m + "。\n本地处理:PrintDot 依赖 SumatraPDF 静默打印 PDF。请安装 SumatraPDF 后任选其一:\n" +
|
||||
"① 将 SumatraPDF.exe 放在 PrintDot 客户端 exe 同目录;\n" +
|
||||
"② 或将 SumatraPDF 安装目录加入系统 PATH;\n" +
|
||||
"③ 或设置用户/系统环境变量 SUMATRAPDF_PATH 指向 SumatraPDF.exe 的完整路径;\n" +
|
||||
"然后重启 PrintDot 桥接器即可。";
|
||||
}
|
||||
return m;
|
||||
}
|
||||
|
||||
private static async Task<string> ReceiveTextAsync(ClientWebSocket ws, CancellationToken ct)
|
||||
{
|
||||
var buffer = new ArraySegment<byte>(new byte[64 * 1024]);
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.6.0" />
|
||||
<!-- 1D 条码渲染(与 web 端 jsbarcode 路径对齐:输出 SVG,由前端 CSS preserveAspectRatio 保比例显示) -->
|
||||
<PackageReference Include="ZXing.Net" Version="0.16.9" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
|
||||
Reference in New Issue
Block a user