diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java index ff49bd1..8ae2096 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/config/shiro/ShiroConfig.java @@ -213,6 +213,8 @@ public class ShiroConfig { filterChainDefinitionMap.put("/xslmes/mesXslWarehouseArea/anon/**", "anon"); // MES密炼物料管理免密接口(供桌面端调用) filterChainDefinitionMap.put("/mes/material/mixerMaterial/anon/**", "anon"); + // 打印模板免密接口(供桌面端调用) + filterChainDefinitionMap.put("/print/template/anon/**", "anon"); // 系统分类字典免密接口(供桌面端调用) filterChainDefinitionMap.put("/sys/category/anon/**", "anon"); // 桌面端用户反同步批量上报(Outbox -> /sys/sync/batch) diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java index 1f72634..0181d8f 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-print/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java @@ -198,6 +198,36 @@ public class PrintTemplateController extends JeecgController anonQueryByCode(@RequestParam(name = "code") String code) { + PrintTemplate t = service.getByCode(code); + if (t == null) { + return Result.error("未找到模板: " + code); + } + return Result.OK(t); + } + + /** + * 免密-分页列表(供 WPF 桌面端匿名调用,用于打印设置页选择模板) + */ + @Operation(summary = "打印模板-免密分页列表(桌面端)") + @GetMapping(value = "/anon/list") + public Result> anonList(PrintTemplate query, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "100") Integer pageSize, + HttpServletRequest req) { + QueryWrapper qw = QueryGenerator.initQueryWrapper(query, req.getParameterMap()); + qw.orderByAsc("template_code"); + Page page = new Page<>(pageNo, pageSize); + return Result.OK(service.page(page, qw)); + } + @AutoLog(value = "打印模板-图片分析生成原生JSON") @Operation(summary = "打印模板-上传图片分析为原生模板JSON(前端传 imageBase64;可接 OpenAI 兼容视觉模型)") @PostMapping(value = "/analyzeImageForNative") diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java index 2ec033d..d55282c 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/service/MesXslStompNotifyService.java @@ -70,6 +70,11 @@ public class MesXslStompNotifyService { publish("/topic/sync/mes-warehouse-areas", "MES_WAREHOUSE_AREA_CHANGED", "warehouseAreaId", warehouseAreaId, action); } + /** 广播打印模板变更事件到 /topic/sync/print-templates */ + public void publishPrintTemplateChanged(String action, String templateId) { + publish("/topic/sync/print-templates", "PRINT_TEMPLATE_CHANGED", "templateId", templateId, action); + } + // ─────────────────────────── 私有辅助 ──────────────────────────── private void publish(String topic, String cmd, String idKey, String idValue, String action) { diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java index 3029843..d9ba191 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintTemplateController.java @@ -48,6 +48,8 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import org.jeecg.modules.print.ai.INativePrintTemplateImageAnalyzeService; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import com.alibaba.fastjson.JSON; /** * 打印模板维护(Hiprint) @@ -63,6 +65,16 @@ public class PrintTemplateController extends JeecgController delete(@RequestParam(name = "id") String id) { service.removeById(id); + publishPrintTemplateChanged("delete", id); return Result.OK("删除成功"); } @@ -153,7 +168,9 @@ public class PrintTemplateController extends JeecgController idList = java.util.Arrays.asList(ids.split(",")); + service.removeByIds(idList); + idList.forEach(id -> publishPrintTemplateChanged("delete", id.trim())); return Result.OK("批量删除成功"); } @@ -450,6 +467,30 @@ public class PrintTemplateController extends JeecgController anonQueryByCode(@RequestParam(name = "code") String code) { + PrintTemplate t = service.getByCode(code); + if (t == null) { + return Result.error("未找到模板: " + code); + } + return Result.OK(t); + } + + @Operation(summary = "打印模板-免密分页列表(桌面端)") + @GetMapping(value = "/anon/list") + public Result> anonList(PrintTemplate query, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "100") Integer pageSize, + HttpServletRequest req) { + QueryWrapper qw = QueryGenerator.initQueryWrapper(query, req.getParameterMap()); + qw.orderByAsc("template_code"); + Page page = new Page<>(pageNo, pageSize); + return Result.OK(service.page(page, qw)); + } + private PrintService resolvePrintService(String printerName) { PrintService target = null; if (StringUtils.isNotBlank(printerName) && !"__system_default__".equals(printerName)) { @@ -469,4 +510,22 @@ public class PrintTemplateController extends JeecgController event = new HashMap<>(); + event.put("cmd", "PRINT_TEMPLATE_CHANGED"); + event.put("action", action); + event.put("templateId", templateId); + event.put("timestamp", System.currentTimeMillis()); + messagingTemplate.convertAndSend("/topic/sync/print-templates", JSON.toJSONString(event)); + } catch (Exception e) { + log.debug("广播 STOMP 事件失败 [PRINT_TEMPLATE_CHANGED]: {}", e.getMessage()); + } + } } diff --git a/yy-admin-master/YY.Admin.Core/Core/Events/PrintTemplateChangedEvent.cs b/yy-admin-master/YY.Admin.Core/Core/Events/PrintTemplateChangedEvent.cs new file mode 100644 index 0000000..da191f2 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Events/PrintTemplateChangedEvent.cs @@ -0,0 +1,11 @@ +using Prism.Events; + +namespace YY.Admin.Core.Events; + +public class PrintTemplateChangedPayload +{ + public string Action { get; set; } = string.Empty; // add/edit/delete/reconnect/pull + public string? TemplateId { get; set; } +} + +public class PrintTemplateChangedEvent : PubSubEvent { } diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/IPrintDotService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/IPrintDotService.cs new file mode 100644 index 0000000..6455084 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/IPrintDotService.cs @@ -0,0 +1,12 @@ +namespace YY.Admin.Core.Services; + +public record PrintDotPrinter(string Name, bool IsDefault); + +public interface IPrintDotService +{ + /// 获取 PrintDot 打印机列表,同时验证连通性。 + Task> GetPrintersAsync(CancellationToken ct = default); + + /// 发送 PDF base64 打印任务(content 可含或不含 data:application/pdf;base64, 前缀)。 + Task PrintAsync(string printerName, string pdfBase64, string jobName = "QH-MES", int copies = 1, CancellationToken ct = default); +} diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/IPrintTemplateService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/IPrintTemplateService.cs new file mode 100644 index 0000000..0dffa5b --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/IPrintTemplateService.cs @@ -0,0 +1,11 @@ +using YY.Admin.Core.Entity; + +namespace YY.Admin.Core.Services; + +public interface IPrintTemplateService +{ + Task GetByCodeAsync(string templateCode, CancellationToken ct = default); + Task> ListAsync(CancellationToken ct = default); + Task> RefreshCacheAsync(CancellationToken ct = default); + IReadOnlyList GetCached(); +} diff --git a/yy-admin-master/YY.Admin.Core/Entity/PrintTemplate.cs b/yy-admin-master/YY.Admin.Core/Entity/PrintTemplate.cs new file mode 100644 index 0000000..7c7feb1 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Entity/PrintTemplate.cs @@ -0,0 +1,20 @@ +namespace YY.Admin.Core.Entity; + +/// 打印模板(映射后端 print_template 表,桌面端只读) +public class PrintTemplate +{ + public string? Id { get; set; } + public string? TemplateCode { get; set; } + public string? TemplateName { get; set; } + public string? Category { get; set; } + public double? PaperWidthMm { get; set; } + public double? PaperHeightMm { get; set; } + public string? PaperOrientation { get; set; } + /// 原生设计器 JSON(engine=native) + public string? TemplateJson { get; set; } + public string? Remark { get; set; } + public string? CreateBy { get; set; } + public DateTime? CreateTime { get; set; } + public string? UpdateBy { get; set; } + public DateTime? UpdateTime { get; set; } +} diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs index b97a9fa..2495a18 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs @@ -77,6 +77,8 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData // 登录设置(桌面端会话与检查间隔) new SysMenu{ Id=1300200013001, Pid=1300200000101, Title="登录设置", Path="LoginSettingsView", Name="loginSettings", Component="LoginSettingsView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=107 }, + // 打印设置(PrintDot 桥接器配置) + new SysMenu{ Id=1300200013101, Pid=1300200000101, Title="打印设置", Path="PrintSettingsView", Name="printSettings", Component="PrintSettingsView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=108 }, // 角色管理 new SysMenu{ Id=1300200020101, Pid=1300200000101, Title="角色管理", Path="RoleManagementView", Name="sysRole", Component="/system/role/index", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=110 }, diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs index e576688..efa571e 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs @@ -218,6 +218,7 @@ public class SysTenantMenuSeedData : ISqlSugarEntitySeedData new SysTenantMenu(){ TenantId=1300000000001,MenuId = 1300200010201}, new SysTenantMenu() { TenantId = 1300000000001,MenuId = 1300600040101}, new SysTenantMenu(){ TenantId=1300000000001, MenuId=1300150010901 }, + new SysTenantMenu(){ TenantId=1300000000001, MenuId=1300200013101 }, }; } diff --git a/yy-admin-master/YY.Admin.Services/Configuration/appsettings.json b/yy-admin-master/YY.Admin.Services/Configuration/appsettings.json index 9abbb4e..c4f3203 100644 --- a/yy-admin-master/YY.Admin.Services/Configuration/appsettings.json +++ b/yy-admin-master/YY.Admin.Services/Configuration/appsettings.json @@ -60,6 +60,9 @@ } ] }, + "PrintDot": { + "Url": "ws://127.0.0.1:1122/ws" // PrintDot 桥接器 WebSocket 地址,可在打印设置页面覆盖 + }, "AutoUpdate": { "RemoteConfigUrl": "http://14.103.155.227:8083/updates/version.xml" //更新文件地址 }, diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs b/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs new file mode 100644 index 0000000..7e232da --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs @@ -0,0 +1,1147 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using System.Globalization; +using QRCoder; + +namespace YY.Admin.Services.Service.Print; + +/// +/// 将后端「原生打印模板」JSON 渲染为 HTML,再通过调用方传入的 WebView2 实例导出 PDF base64。 +/// 支持元素类型:text/title/subtitle/date/pageNo/qrcode/barcode/image/freeTable/table/detailTable。 +/// +public static class NativePrintRenderService +{ + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNameCaseInsensitive = true }; + + // ─── 入口 ────────────────────────────────────────────────────────────── + + /// + /// 将模板 JSON + 数据对象渲染为完整的可打印 HTML 页面(自包含,无外部依赖)。 + /// 屏幕样式与后端前端预览保持一致:深灰底(#525659)+ 白纸居中 + 页间分割线。 + /// + public static string RenderToHtml(string templateJson, JsonObject data) + { + var schema = JsonNode.Parse(templateJson) ?? throw new ArgumentException("无效模板 JSON"); + var page = schema["page"] ?? throw new ArgumentException("模板缺少 page 配置"); + + var widthMm = page["width"]?.GetValue() ?? 210; + var heightMm = page["height"]?.GetValue() ?? 297; + var margin = page["margin"]?.AsArray(); + var mt = margin?[0]?.GetValue() ?? 0; + var mr = margin?[1]?.GetValue() ?? 0; + var mb = margin?[2]?.GetValue() ?? 0; + var ml = margin?[3]?.GetValue() ?? 0; + var pageMarginCss = (mt == 0 && mr == 0 && mb == 0 && ml == 0) ? "0mm" + : $"{mt.ToString("0.###", CultureInfo.InvariantCulture)}mm {mr.ToString("0.###", CultureInfo.InvariantCulture)}mm {mb.ToString("0.###", CultureInfo.InvariantCulture)}mm {ml.ToString("0.###", CultureInfo.InvariantCulture)}mm"; + + var elements = schema["elements"]?.AsArray() ?? []; + + // ─ 提取 reportHeader 配置 ─ + var reportHeaderEl = elements.OfType() + .FirstOrDefault(el => ReadAsString(el["type"]) == "reportHeader"); + var reportHeaderId = ReadAsString(reportHeaderEl?["id"]) ?? string.Empty; + var headerVisible = reportHeaderEl == null || + !string.Equals(ReadAsString(reportHeaderEl["visible"]), "false", StringComparison.OrdinalIgnoreCase); + var repeatHeaderByPage = headerVisible && reportHeaderEl != null && + string.Equals(ReadAsString(reportHeaderEl["printRepeated"]), "true", StringComparison.OrdinalIgnoreCase); + var headerBandHeight = repeatHeaderByPage + ? Math.Max(0d, reportHeaderEl!["h"]?.GetValue() ?? 0d) : 0d; + + var pageCount = ResolvePrintPageCount(elements, data, heightMm, mt, mb, headerBandHeight, repeatHeaderByPage); + var totalHeightMm = Math.Max(heightMm, heightMm * pageCount); + var wStr = widthMm.ToString("0.###", CultureInfo.InvariantCulture); + var hStr = heightMm.ToString("0.###", CultureInfo.InvariantCulture); + var thStr = totalHeightMm.ToString("0.###", CultureInfo.InvariantCulture); + + var sb = new StringBuilder(); + sb.Append("\n\n\n\n"); + sb.Append("\n"); + sb.Append("\n\n"); + sb.Append($"
\n"); + + // 按 zIndex 升序渲染(低 zIndex 先画,高 zIndex 覆盖在上层) + var sortedElements = elements.OfType() + .OrderBy(el => el?["zIndex"]?.GetValue() ?? 0) + .ToList(); + + foreach (var el in sortedElements) + { + if (el == null) continue; + var elHtml = RenderElement(el, data, widthMm, heightMm, pageCount, mt, mb, elements, + repeatHeaderByPage, headerBandHeight, reportHeaderId, headerVisible); + if (!string.IsNullOrEmpty(elHtml)) + sb.Append(elHtml); + } + + for (var i = 1; i < pageCount; i++) + { + var sepTop = (i * heightMm).ToString("0.###", CultureInfo.InvariantCulture); + sb.Append($"
\n"); + sb.Append($"
\n"); + } + + sb.Append("
\n"); + return sb.ToString(); + } + + // ─── 元素渲染分发 ────────────────────────────────────────────────────── + + private static string RenderElement( + JsonNode el, JsonObject data, + double pageWidthMm, double pageHeightMm, int totalPages, + double marginTopMm, double marginBottomMm, JsonArray allElements, + bool repeatHeaderByPage = false, double headerBandHeight = 0d, + string reportHeaderId = "", bool headerVisible = true) + { + // visible=false 的元素跳过(与前端一致) + if (string.Equals(ReadAsString(el["visible"]), "false", StringComparison.OrdinalIgnoreCase)) + return string.Empty; + + var type = ReadAsString(el["type"], "text"); + var x = el["x"]?.GetValue() ?? 0; + var y = el["y"]?.GetValue() ?? 0; + var w = el["w"]?.GetValue() ?? 20; + var h = el["h"]?.GetValue() ?? 8; + var zIndex = el["zIndex"]?.GetValue() ?? 0; + var rotate = el["rotate"]?.GetValue() ?? 0; + + var style = el["style"]; + var fs = style?["fontSize"]?.GetValue() ?? 12; + var fw = ReadAsString(style?["fontWeight"], "400"); + var color = ReadAsString(style?["color"], "#111111"); + var align = ReadAsString(style?["textAlign"], "left"); + var lh = style?["lineHeight"]?.GetValue() ?? 1.4; + var bg = ReadAsString(style?["backgroundColor"]); + + var bgCss = bg != null ? $"background:{bg};" : string.Empty; + var rotCss = rotate != 0 ? $"transform:rotate({rotate}deg);transform-origin:top left;" : string.Empty; + + // ─ reportHeader/reportFooter 位置覆盖 ─ + var isReportHeader = type == "reportHeader"; + var isReportFooter = type == "reportFooter"; + double renderX = (isReportHeader || isReportFooter) ? 0 : x; + double renderW = (isReportHeader || isReportFooter) ? pageWidthMm : w; + double renderY = isReportHeader ? 0 + : (isReportFooter && string.Equals(ReadAsString(el["printAtPageBottom"]), "true", StringComparison.OrdinalIgnoreCase)) + ? Math.Max(0, pageHeightMm - h) + : y; + + // ─ reportHeader band 判断 ─ + var isInHeaderBand = IsElementInHeaderBand(type, reportHeaderId, + ReadAsString(el["bandId"]) ?? string.Empty, + ReadAsString(el["region"]) ?? string.Empty, + headerBandHeight, y, h); + if (isInHeaderBand && !headerVisible) return string.Empty; + + // 需要在每页重复:元素在报表头区域 且 报表头开启了 printRepeated + var bandRepeat = isInHeaderBand && repeatHeaderByPage && totalPages > 1; + + var posStyle = $"left:{renderX.ToString("0.###", CultureInfo.InvariantCulture)}mm;" + + $"top:{renderY.ToString("0.###", CultureInfo.InvariantCulture)}mm;" + + $"width:{renderW.ToString("0.###", CultureInfo.InvariantCulture)}mm;" + + $"height:{h.ToString("0.###", CultureInfo.InvariantCulture)}mm;" + + $"z-index:{zIndex};{bgCss}{rotCss}"; + + return type switch + { + "title" or "subtitle" or "text" or "date" or "pageNo" or "reportHeader" or "reportFooter" + => RenderText(el, data, posStyle, fs, fw, color, align, lh, renderY, pageHeightMm, totalPages, bandRepeat), + "qrcode" => RenderQrCode(el, data, posStyle, renderW, h, bandRepeat, renderY, pageHeightMm, totalPages), + "barcode" => RenderBarcode(el, data, posStyle, renderW, h, bandRepeat, renderY, pageHeightMm, totalPages), + "image" => RenderImage(el, data, posStyle, bandRepeat, renderY, pageHeightMm, totalPages), + "freeTable" => RenderFreeTable(el, data, posStyle, renderW, h, renderY, + totalPages, pageHeightMm, bandRepeat), + "table" or "detailTable" => RenderDetailTable(el, data, posStyle, y, pageHeightMm, + marginTopMm, marginBottomMm, allElements, repeatHeaderByPage, headerBandHeight), + _ => string.Empty + }; + } + + /// + /// 判断元素是否属于报表头区域(与前端 isElementInHeaderRegion 一致)。 + /// + private static bool IsElementInHeaderBand( + string? type, string reportHeaderId, + string bandId, string region, + double headerBandHeight, double y, double h) + { + if (type == "reportHeader") return true; + if (type == "reportFooter") return false; + if (!string.IsNullOrEmpty(reportHeaderId) && bandId == reportHeaderId) return true; + if (region == "header") return true; + if (region is "body" or "footer") return false; + if (headerBandHeight <= 0) return false; + // 位置回退判断:元素顶部在 band 内且底部不超出 band 0.2mm + return y < headerBandHeight && (y + h) <= headerBandHeight + 0.2; + } + + // ─── Text ────────────────────────────────────────────────────────────── + + private static string RenderText(JsonNode el, JsonObject data, string posStyle, + double fs, string fw, string color, string align, double lh, + double designY, double pageHeightMm, int totalPages, bool bandRepeat = false) + { + var type = ReadAsString(el["type"], "text"); + var bindField = ReadAsString(el["bindField"]); + string text; + + if (type == "date") + { + var val = bindField != null ? ResolveField(data, bindField)?.ToString() : null; + var fmt = ReadAsString(el["format"], "yyyy-MM-dd"); + text = val != null && DateTime.TryParse(val, out var dt) + ? dt.ToString(ConvertDateFormat(fmt)) + : DateTime.Now.ToString(ConvertDateFormat(fmt)); + } + else if (bindField != null) + { + text = ResolveField(data, bindField)?.ToString() ?? ReadAsString(el["text"], string.Empty); + } + else + { + text = ReadAsString(el["text"], string.Empty); + } + var textCss = $"font-size:{fs}px;font-weight:{fw};color:{color};text-align:{align};line-height:{lh};white-space:pre-wrap;overflow:hidden;word-break:break-all;"; + // 元素自身 printRepeated 或 pageNo 类型,或报表头区域强制重复 + var repeat = bandRepeat || type == "pageNo" + || string.Equals(ReadAsString(el["printRepeated"], "false"), "true", StringComparison.OrdinalIgnoreCase); + + if (!repeat || totalPages <= 1) + { + text = ReplaceTemplatePlaceholders(text, data, 1, totalPages); + return $"
{EscapeHtml(text)}
\n"; + } + + var baseStyle = RemoveCssProperty(posStyle, "top"); + var sb = new StringBuilder(); + for (var p = 1; p <= totalPages; p++) + { + var top = designY + (p - 1) * pageHeightMm; + var textPerPage = ReplaceTemplatePlaceholders(text, data, p, totalPages); + sb.Append($"
{EscapeHtml(textPerPage)}
\n"); + } + return sb.ToString(); + } + + // ─── QR Code ──────────────────────────────────────────────────────────── + + private static string RenderQrCode(JsonNode el, JsonObject data, string posStyle, double w, double h, + bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1) + { + var bindField = ReadAsString(el["bindField"]); + var value = ReadAsString(el["value"], string.Empty); + if (bindField != null) + value = ResolveField(data, bindField)?.ToString() ?? value; + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + string inner; + try + { + using var gen = new QRCodeGenerator(); + var qrData = gen.CreateQrCode(value, QRCodeGenerator.ECCLevel.M); + using var code = new PngByteQRCode(qrData); + var b64 = Convert.ToBase64String(code.GetGraphic(6)); + inner = $""; + } + catch { return string.Empty; } + + var wrapStyle = "display:flex;align-items:center;justify-content:center;"; + if (!bandRepeat || totalPages <= 1) + return $"
{inner}
\n"; + + var baseNoTop = RemoveCssProperty(posStyle, "top"); + var sb = new StringBuilder(); + for (var p = 0; p < totalPages; p++) + { + var top = designY + p * pageHeightMm; + sb.Append($"
{inner}
\n"); + } + return sb.ToString(); + } + + // ─── Barcode ──────────────────────────────────────────────────────────── + + 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) + { + var bindField = ReadAsString(el["bindField"]); + var value = ReadAsString(el["value"], string.Empty); + if (bindField != null) + value = ResolveField(data, bindField)?.ToString() ?? value; + if (string.IsNullOrWhiteSpace(value)) return string.Empty; + + var escapedVal = EscapeHtml(value); + var inner = $"" + + $"" + + $"{escapedVal}" + + $"{escapedVal}"; + var wrapStyle = "display:flex;flex-direction:column;align-items:center;justify-content:center;"; + + if (!bandRepeat || totalPages <= 1) + return $"
{inner}
\n"; + + var baseNoTop = RemoveCssProperty(posStyle, "top"); + var sb = new StringBuilder(); + for (var p = 0; p < totalPages; p++) + { + var top = designY + p * pageHeightMm; + sb.Append($"
{inner}
\n"); + } + return sb.ToString(); + } + + // ─── Image ───────────────────────────────────────────────────────────── + + private static string RenderImage(JsonNode el, JsonObject data, string posStyle, + bool bandRepeat = false, double designY = 0, double pageHeightMm = 0, int totalPages = 1) + { + var bindField = ReadAsString(el["bindField"]); + var src = ReadAsString(el["src"], string.Empty); + if (bindField != null) + src = ResolveField(data, bindField)?.ToString() ?? src; + var fit = ReadAsString(el["fit"], "contain"); + var objFit = fit switch { "fill" => "fill", "cover" => "cover", _ => "contain" }; + var inner = $""; + + if (!bandRepeat || totalPages <= 1) + return $"
{inner}
\n"; + + var baseNoTop = RemoveCssProperty(posStyle, "top"); + var sb = new StringBuilder(); + for (var p = 0; p < totalPages; p++) + { + var top = designY + p * pageHeightMm; + sb.Append($"
{inner}
\n"); + } + return sb.ToString(); + } + + // ─── FreeTable ────────────────────────────────────────────────────────── + + // printRepeated=true 或 bandRepeat=true 时在每页重复;否则仅在第1页显示 + private static string RenderFreeTable(JsonNode el, JsonObject data, string posStyle, double wMm, double hMm, + double designY, int totalPages, double pageHeightMm, bool bandRepeat = false) + { + var innerHtml = BuildFreeTableInnerHtml(el, data, wMm, hMm); + var printRepeat = string.Equals(ReadAsString(el["printRepeated"]), "true", StringComparison.OrdinalIgnoreCase); + var repeat = (printRepeat || bandRepeat) && totalPages > 1; + var baseNoTop = RemoveCssProperty(RemoveCssProperty(posStyle, "height"), "top"); + + if (!repeat) + return $"
{innerHtml}
\n"; + + var sb = new StringBuilder(); + for (var p = 0; p < totalPages; p++) + { + var top = designY + p * pageHeightMm; + sb.Append($"
{innerHtml}
\n"); + } + return sb.ToString(); + } + + private static string BuildFreeTableInnerHtml(JsonNode el, JsonObject data, double wMm, double hMm) + { + var rowCount = el["rowCount"]?.GetValue() ?? 1; + var colCount = el["colCount"]?.GetValue() ?? 1; + var cells = el["cells"]?.AsArray() ?? []; + var borderColor = ReadAsString(el["borderColor"], "#222222"); + var borderWidth = el["borderWidth"]?.GetValue() ?? 1; + var colWidths = el["colWidths"]?.AsArray()?.Select(n => n?.GetValue() ?? wMm / colCount).ToArray() + ?? Enumerable.Repeat(wMm / colCount, colCount).ToArray(); + var rowHeights = el["rowHeights"]?.AsArray()?.Select(n => n?.GetValue() ?? hMm / rowCount).ToArray() + ?? Enumerable.Repeat(hMm / rowCount, rowCount).ToArray(); + + var cellMap = new Dictionary<(int row, int col), JsonNode>(); + foreach (var c in cells) + if (c != null) + cellMap[(c["row"]?.GetValue() ?? 0, c["col"]?.GetValue() ?? 0)] = c; + + var occupied = new HashSet<(int, int)>(); + var sb = new StringBuilder(); + sb.Append($""); + sb.Append(""); + foreach (var cw in colWidths) sb.Append($""); + sb.Append(""); + + for (var r = 0; r < rowCount; r++) + { + var rh = r < rowHeights.Length ? rowHeights[r] : hMm / rowCount; + sb.Append($""); + for (var c = 0; c < colCount; c++) + { + if (occupied.Contains((r, c))) continue; + cellMap.TryGetValue((r, c), out var cell); + var rs = cell?["rowspan"]?.GetValue() ?? 1; + var cs = cell?["colspan"]?.GetValue() ?? 1; + var bindField = ReadAsString(cell?["bindField"]); + var rawText = ReadAsString(cell?["text"], string.Empty); + var cellText = bindField != null + ? ResolveField(data, bindField)?.ToString() ?? rawText + : rawText; + var ta = ReadAsString(cell?["align"], "left"); + var va = ReadAsString(cell?["verticalAlign"], "middle"); + var cFs = cell?["fontSize"]?.GetValue() ?? 12; + var cColor = ReadAsString(cell?["color"], "#111111"); + var cBg = ReadAsString(cell?["backgroundColor"], "#ffffff"); + for (var dr = 0; dr < rs; dr++) + for (var dc = 0; dc < cs; dc++) + if (dr > 0 || dc > 0) occupied.Add((r + dr, c + dc)); + var rsAttr = rs > 1 ? $" rowspan=\"{rs}\"" : string.Empty; + var csAttr = cs > 1 ? $" colspan=\"{cs}\"" : string.Empty; + var bdr = $"border:{borderWidth}px solid {borderColor};"; + sb.Append($""); + sb.Append(EscapeHtml(cellText ?? string.Empty)); + sb.Append(""); + } + sb.Append(""); + } + sb.Append("
"); + return sb.ToString(); + } + + // ─── Table / DetailTable ─────────────────────────────────────────────── + + private static string RenderDetailTable(JsonNode el, JsonObject data, string posStyle, + double designY, double pageHeightMm, double marginTopMm, double marginBottomMm, + JsonArray allElements, bool repeatHeaderByPage = false, double headerBandHeight = 0d) + { + var source = ReadAsString(el["source"], "mainTable"); + var showHeader = !string.Equals(ReadAsString(el["showHeader"], "true"), "false", StringComparison.OrdinalIgnoreCase); + var rowHeightMm = Math.Max(6d, el["rowHeight"]?.GetValue() ?? 8d); + var headerBg = ReadAsString(el["headerBgColor"], "#f5f5f5"); + var headerText = ReadAsString(el["headerTextColor"], "#111111"); + var columns = el["columns"]?.AsArray() ?? []; + if (columns.Count == 0) return string.Empty; + + var rows = ResolveTableRows(data, source); + var footerMode = (ReadAsString(el["footerTotalMode"], "overall") ?? "overall").ToLowerInvariant(); + var showFooter = !string.Equals(ReadAsString(el["footerShowTotal"], "true"), "false", StringComparison.OrdinalIgnoreCase); + + // 行合并配置 + var mergeColumnKeys = el["mergeColumnKeys"]?.AsArray() + ?.Select(n => ReadAsString(n) ?? string.Empty) + .Where(s => !string.IsNullOrEmpty(s)) + .ToArray() ?? []; + var strictGrouping = !string.Equals(ReadAsString(el["strictGrouping"], "true"), "false", StringComparison.OrdinalIgnoreCase); + + var detailH = el["h"]?.GetValue() ?? 8d; + var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(allElements, designY, detailH); + + List> chunks; + var tableMode = (ReadAsString(el["tableHeightMode"], "autoPage") ?? "autoPage").ToLowerInvariant(); + if (tableMode == "fixedrows") + { + var pageSize = Math.Max(1, el["fixedRows"]?.GetValue() ?? 5); + chunks = ChunkRowsFixed(rows, pageSize); + } + else + { + chunks = ComputeAutoPageChunks(el, rows, columns, designY, pageHeightMm, + marginTopMm, marginBottomMm, showFooter, footerMode, rowHeightMm, + repeatFreeConstrains, headerBandHeight); + } + + var sb = new StringBuilder(); + var baseStyleNoTop = RemoveCssProperty(RemoveCssProperty(posStyle, "height"), "top"); + for (var pageIdx = 0; pageIdx < chunks.Count; pageIdx++) + { + // 续页起点: + // - repeatFreeConstrains → 沿用 designY(重复自由表格占据了顶部空间) + // - 否则 → 顶排到页面上边距 + 报表头带高度(消除空白) + double top; + if (pageIdx == 0 || repeatFreeConstrains) + { + top = designY + pageIdx * pageHeightMm; + } + else + { + top = pageIdx * pageHeightMm + marginTopMm + + (repeatHeaderByPage ? headerBandHeight : 0d); + } + + var isLastChunk = pageIdx == chunks.Count - 1; + var chunkRows = chunks[pageIdx]; + + // 计算当前 chunk 的行合并 rowSpanMap(按 chunk 内相对行号) + var rowSpanMap = BuildRowSpanMap(chunkRows, columns, mergeColumnKeys, strictGrouping); + + sb.Append($"
"); + sb.Append(""); + + if (showHeader) + { + var headerRows = BuildTableHeaderRows(el, columns.OfType().ToList()); + var headerHeightMm = Math.Max(6d, el["headerHeight"]?.GetValue() ?? 10d); + var headerRowHeightMm = headerHeightMm / Math.Max(1, headerRows.Count); + sb.Append(""); + foreach (var hrow in headerRows) + { + sb.Append($""); + foreach (var cell in hrow) + { + var rs = cell.RowSpan > 1 ? $" rowspan=\"{cell.RowSpan}\"" : string.Empty; + var cs = cell.ColSpan > 1 ? $" colspan=\"{cell.ColSpan}\"" : string.Empty; + sb.Append($"{EscapeHtml(cell.Title)}"); + } + sb.Append(""); + } + sb.Append(""); + } + + sb.Append(""); + for (var rowIdx = 0; rowIdx < chunkRows.Count; rowIdx++) + { + var row = chunkRows[rowIdx]; + sb.Append($""); + foreach (var col in columns) + { + var field = ReadAsString(col?["bindField"]) ?? ReadAsString(col?["field"]); + var spanKey = $"{rowIdx}_{field ?? string.Empty}"; + if (rowSpanMap.TryGetValue(spanKey, out var spanVal) && spanVal == 0) + continue; // 被上方行合并,跳过此格 + + var rowspanAttr = (rowSpanMap.TryGetValue(spanKey, out var rsVal) && rsVal > 1) + ? $" rowspan=\"{rsVal}\"" + : string.Empty; + + var align = ReadAsString(col?["align"], "left"); + var contentType = ReadAsString(col?["contentType"], "text"); + var autoWrap = !string.Equals(ReadAsString(col?["autoWrap"], "true"), "false", StringComparison.OrdinalIgnoreCase); + var raw = !string.IsNullOrWhiteSpace(field) ? ResolveField(row, field!) : null; + var text = FormatColumnValue(raw, col, contentType); + var html = ResolveTableCellInnerHtml(contentType, text); + var ws = autoWrap ? "normal" : "nowrap"; + var wb = autoWrap ? "break-all" : "normal"; + var lhv = autoWrap ? "1.3" : $"{rowHeightMm.ToString("0.###", CultureInfo.InvariantCulture)}mm"; + sb.Append($"{html}"); + } + sb.Append(""); + } + sb.Append(""); + + if (showFooter && (footerMode == "page" || isLastChunk)) + { + var footerRows = footerMode == "page" ? chunkRows : rows; + sb.Append(BuildFooterHtml(el, footerRows, columns)); + } + + sb.Append("
"); + } + return sb.ToString(); + } + + // ─── 行合并 ──────────────────────────────────────────────────────────── + + /// + /// 移植自前端 buildRowSpanMap。返回 "{rowIdx}_{field}" → rowspan 的映射; + /// 值为 0 表示该格被上方行合并,渲染时跳过;值 > 1 表示需加 rowspan 属性。 + /// + private static Dictionary BuildRowSpanMap( + List rows, JsonArray columns, + string[] mergeColumnKeys, bool strictGrouping) + { + var map = new Dictionary(); + if (rows.Count == 0) return map; + + var mergeFields = ResolveMergeFields(columns, mergeColumnKeys); + if (mergeFields.Count == 0) return map; + + var currentRanges = new List<(int start, int end)> { (0, rows.Count) }; + foreach (var field in mergeFields) + { + var (fieldMap, nextRanges) = BuildRangesByField(rows, field, currentRanges); + foreach (var kv in fieldMap) map[kv.Key] = kv.Value; + if (strictGrouping) currentRanges = nextRanges; + } + return map; + } + + private static List ResolveMergeFields(JsonArray columns, string[] mergeColumnKeys) + { + var colList = columns.OfType().ToList(); + if (mergeColumnKeys.Length > 0) + { + var byKey = colList.ToDictionary( + c => ReadAsString(c["key"]) ?? string.Empty, + c => ReadAsString(c["bindField"]) ?? ReadAsString(c["field"]) ?? string.Empty); + return mergeColumnKeys + .Select(k => byKey.TryGetValue(k, out var f) ? f : string.Empty) + .Where(f => !string.IsNullOrEmpty(f)) + .ToList(); + } + return colList + .Where(c => string.Equals(ReadAsString(c["mergeByValue"]), "true", StringComparison.OrdinalIgnoreCase)) + .Select(c => ReadAsString(c["bindField"]) ?? ReadAsString(c["field"]) ?? string.Empty) + .Where(f => !string.IsNullOrEmpty(f)) + .ToList(); + } + + private static (Dictionary map, List<(int start, int end)> nextRanges) + BuildRangesByField(List rows, string field, List<(int start, int end)> ranges) + { + var map = new Dictionary(); + var nextRanges = new List<(int start, int end)>(); + foreach (var (rangeStart, rangeEnd) in ranges) + { + var pos = rangeStart; + while (pos < rangeEnd) + { + var value = ResolveField(rows[pos], field)?.ToString(); + var end = pos + 1; + while (end < rangeEnd && ResolveField(rows[end], field)?.ToString() == value) + end++; + map[$"{pos}_{field}"] = end - pos; + for (var i = pos + 1; i < end; i++) + map[$"{i}_{field}"] = 0; + nextRanges.Add((pos, end)); + pos = end; + } + } + return (map, nextRanges); + } + + // ─── 分页算法 ────────────────────────────────────────────────────────── + + private static List> ChunkRowsFixed(List rows, int pageSize) + { + var size = Math.Max(1, pageSize); + if (rows.Count == 0) return new List> { new() }; + var result = new List>(); + for (var i = 0; i < rows.Count; i += size) + result.Add(rows.Skip(i).Take(size).ToList()); + return result; + } + + private static List> ComputeAutoPageChunks( + JsonNode el, List rows, JsonArray columns, + double designY, double pageHeightMm, double marginTopMm, double marginBottomMm, + bool showFooter, string footerMode, double rowHeightMm, + bool repeatFreeConstrains = false, double headerBandMm = 0d) + { + if (rows.Count == 0) return new List> { new() }; + + var showHeader = !string.Equals(ReadAsString(el["showHeader"], "true"), "false", StringComparison.OrdinalIgnoreCase); + var headerHeightMm = showHeader ? Math.Max(6d, el["headerHeight"]?.GetValue() ?? 10d) : 0d; + var footerMm = showFooter ? Math.Max(rowHeightMm, 6d) : 0d; + var innerH = pageHeightMm - marginTopMm - marginBottomMm; + var colList = columns.OfType().ToList(); + var rowHeights = rows.Select(r => EstimateRowHeightMm(r, colList, rowHeightMm)).ToList(); + + const double insetMm = 1.4; + const double fitFactor = 1.03; + + var chunks = new List>(); + var i = 0; + var pageIdx = 0; + + while (i < rows.Count) + { + var remaining = rows.Count - i; + // repeatFreeConstrains → 续页可用高度同首页(重复自由表格占据了 designY 空间) + // 否则 → 续页顶排,减去报表头带高度(headerBandMm) + var avail = pageIdx == 0 + ? innerH - designY - headerHeightMm + : repeatFreeConstrains + ? Math.Max(rowHeightMm, innerH - designY - headerHeightMm) + : Math.Max(rowHeightMm, innerH - headerHeightMm - headerBandMm); + var safeAvail = Math.Max(rowHeightMm, avail - insetMm); + + var needFooter = showFooter && footerMode == "page"; + var maxBodyMm = needFooter ? Math.Max(rowHeightMm, safeAvail - footerMm) : safeAvail; + + if (showFooter && footerMode != "page" && remaining <= CalcMaxRows(rowHeights, i, remaining, fitFactor, safeAvail)) + { + var bodyMm = rowHeights.Skip(i).Take(remaining).Sum(h => h * fitFactor); + if (bodyMm + footerMm <= safeAvail + 0.02) + { + chunks.Add(rows.Skip(i).Take(remaining).ToList()); + break; + } + maxBodyMm = Math.Max(rowHeightMm, safeAvail - footerMm); + } + + var take = 0; + var used = 0d; + while (take < remaining && used + rowHeights[i + take] * fitFactor <= maxBodyMm + 0.02) + { + used += rowHeights[i + take] * fitFactor; + take++; + } + if (take == 0) take = 1; + + chunks.Add(rows.Skip(i).Take(take).ToList()); + i += take; + pageIdx += 1; + if (pageIdx > 5000) break; + } + return chunks.Count > 0 ? chunks : new List> { new() }; + } + + private static int CalcMaxRows(List heights, int from, int count, double factor, double avail) + { + var used = 0d; + var n = 0; + for (var j = from; j < from + count && j < heights.Count; j++) + { + if (used + heights[j] * factor > avail + 0.02) break; + used += heights[j] * factor; + n++; + } + return n; + } + + private static double EstimateRowHeightMm(JsonObject row, List columns, double baseRowMm) + { + const double padHmm = 4d; + const double mediaExtra = 1.25d; + const double pxPerMm = 96d / 25.4d; + const double mmPerPx = 25.4d / 96d; + var maxH = baseRowMm; + + foreach (var col in columns) + { + var contentType = (ReadAsString(col["contentType"], "text") ?? "text").ToLowerInvariant(); + var colWMm = Math.Max(1d, col["width"]?.GetValue() ?? 30d); + + if (contentType is "qrcode" or "barcode") + { + maxH = Math.Max(maxH, colWMm * 0.93 + padHmm + mediaExtra); + continue; + } + if (contentType == "image") + { + maxH = Math.Max(maxH, colWMm * 0.65 + padHmm); + continue; + } + + var autoWrap = !string.Equals(ReadAsString(col["autoWrap"], "true"), "false", StringComparison.OrdinalIgnoreCase); + if (!autoWrap) { maxH = Math.Max(maxH, baseRowMm); continue; } + + var field = ReadAsString(col["bindField"]) ?? ReadAsString(col["field"]); + var raw = field != null ? ResolveField(row, field)?.ToString() ?? string.Empty : string.Empty; + if (string.IsNullOrEmpty(raw)) { maxH = Math.Max(maxH, baseRowMm); continue; } + + var fs = col["fontSize"]?.GetValue() ?? 12d; + var innerWpx = Math.Max(8d, colWMm * pxPerMm - 4d * pxPerMm); + var lines = EstimateTextWrapLines(raw, innerWpx, fs); + var textHmm = lines * fs * 1.3 * mmPerPx; + maxH = Math.Max(maxH, Math.Max(textHmm + padHmm, baseRowMm)); + } + return maxH; + } + + private static int EstimateTextWrapLines(string text, double innerWpx, double fontSize) + { + if (string.IsNullOrEmpty(text)) return 1; + var units = 0d; + foreach (var ch in text) + { + var cp = (int)ch; + if (IsFullWidthChar(cp)) units += 1d; + else if (char.IsWhiteSpace(ch)) units += 0.22d; + else units += 0.62d; + } + units = Math.Max(0.01, units); + var unitsPerLine = Math.Max(1.8, innerWpx / Math.Max(1, fontSize) * 0.97); + return Math.Max(1, (int)Math.Ceiling(units / unitsPerLine)); + } + + private static bool IsFullWidthChar(int cp) => + (cp >= 0x2E80 && cp <= 0x9FFF) || + (cp >= 0x3040 && cp <= 0x30FF) || + (cp >= 0xAC00 && cp <= 0xD7AF) || + (cp >= 0xF900 && cp <= 0xFAFF) || + (cp >= 0xFF00 && cp <= 0xFFEF); + + // ─── 合计行 ───────────────────────────────────────────────────────────── + + private static string BuildFooterHtml(JsonNode el, List rows, JsonArray columns) + { + var labelColumnKey = ReadAsString(el["footerLabelColumnKey"]) ?? string.Empty; + var labelText = ReadAsString(el["footerLabelText"], "合计") ?? "合计"; + var labelCenter = !string.Equals(ReadAsString(el["footerLabelCenter"], "true"), "false", StringComparison.OrdinalIgnoreCase); + var labelAlign = labelCenter ? "center" : "left"; + + var sb = new StringBuilder(""); + var colList = columns.OfType().ToList(); + for (var idx = 0; idx < colList.Count; idx++) + { + var col = colList[idx]; + var contentType = (ReadAsString(col["contentType"], "text") ?? "text").ToLowerInvariant(); + var isNumeric = contentType is "number" or "amount"; + var enableTotal = string.Equals(ReadAsString(col["enableFooterTotal"]), "true", StringComparison.OrdinalIgnoreCase); + var colKey = ReadAsString(col["key"]) ?? string.Empty; + + string cellText; + var cellAlign = "left"; + + if (isNumeric && enableTotal) + { + var field = ReadAsString(col["bindField"]) ?? ReadAsString(col["field"]); + var total = 0d; + if (field != null) + foreach (var row in rows) + { + var raw = ResolveField(row, field)?.ToString(); + if (raw != null && double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var d)) + total += d; + } + cellText = FormatColumnValue(total, col, contentType); + cellAlign = ReadAsString(col["align"], "right") ?? "right"; + } + else if ((!string.IsNullOrEmpty(labelColumnKey) && colKey == labelColumnKey) + || (string.IsNullOrEmpty(labelColumnKey) && idx == 0)) + { + cellText = labelText; + cellAlign = labelAlign; + } + else + { + cellText = string.Empty; + } + + sb.Append($"{EscapeHtml(cellText)}"); + } + sb.Append(""); + return sb.ToString(); + } + + // ─── 页数计算 ─────────────────────────────────────────────────────────── + + private static int ResolvePrintPageCount( + JsonArray elements, JsonObject data, + double pageHeightMm, double marginTopMm, double marginBottomMm, + double headerBandHeight = 0d, bool repeatHeaderByPage = false) + { + var headerBandMm = repeatHeaderByPage ? headerBandHeight : 0d; + var pages = 1; + foreach (var el in elements.OfType()) + { + var type = ReadAsString(el["type"], string.Empty); + if (type is not ("table" or "detailTable")) continue; + + var source = ReadAsString(el["source"], "mainTable"); + var rows = ResolveTableRows(data, source); + var columns = el["columns"]?.AsArray() ?? new JsonArray(); + var designY = el["y"]?.GetValue() ?? 0d; + var rowHeightMm = Math.Max(6d, el["rowHeight"]?.GetValue() ?? 8d); + var footerMode = (ReadAsString(el["footerTotalMode"], "overall") ?? "overall").ToLowerInvariant(); + var showFooter = !string.Equals(ReadAsString(el["footerShowTotal"], "true"), "false", StringComparison.OrdinalIgnoreCase); + var tableMode = (ReadAsString(el["tableHeightMode"], "autoPage") ?? "autoPage").ToLowerInvariant(); + + int tablePages; + if (tableMode == "fixedrows") + { + var pageSize = Math.Max(1, el["fixedRows"]?.GetValue() ?? 5); + tablePages = Math.Max(1, (int)Math.Ceiling(rows.Count / (double)pageSize)); + } + else + { + var repeatFreeConstrains = RepeatingFreeTableConstrainsDetail(elements, designY, el["h"]?.GetValue() ?? 8d); + var chunks = ComputeAutoPageChunks(el, rows, columns, designY, pageHeightMm, + marginTopMm, marginBottomMm, showFooter, footerMode, rowHeightMm, + repeatFreeConstrains, headerBandMm); + tablePages = Math.Max(1, chunks.Count); + } + pages = Math.Max(pages, tablePages); + } + return Math.Max(1, pages); + } + + // ─── 自由表格约束判断 ────────────────────────────────────────────────── + + private static bool RepeatingFreeTableConstrainsDetail(JsonArray elements, double detailY, double detailH) + { + var dBottom = detailY + Math.Max(0.01, detailH); + foreach (var el in elements.OfType()) + { + if (!string.Equals(ReadAsString(el["type"]), "freeTable", StringComparison.OrdinalIgnoreCase)) continue; + if (string.Equals(ReadAsString(el["visible"]), "false", StringComparison.OrdinalIgnoreCase)) continue; + if (!string.Equals(ReadAsString(el["printRepeated"]), "true", StringComparison.OrdinalIgnoreCase)) continue; + var fy = el["y"]?.GetValue() ?? 0d; + if (fy >= dBottom - 0.02) continue; + return true; + } + return false; + } + + // ─── 表格行数据 & 单元格渲染 ─────────────────────────────────────────── + + private static List ResolveTableRows(JsonObject data, string? source) + { + var key = string.IsNullOrWhiteSpace(source) ? "mainTable" : source.Trim(); + var node = ResolveField(data, key) as JsonNode; + if (node is JsonArray arr) + return arr.OfType().ToList(); + return new List(); + } + + private static string ResolveTableCellInnerHtml(string? contentType, string value) + { + var t = (contentType ?? "text").Trim().ToLowerInvariant(); + if (t == "qrcode") + { + try + { + using var gen = new QRCodeGenerator(); + var qrData = gen.CreateQrCode(value, QRCodeGenerator.ECCLevel.M); + using var code = new PngByteQRCode(qrData); + var b64 = Convert.ToBase64String(code.GetGraphic(6)); + return $""; + } + catch { return EscapeHtml(value); } + } + if (t == "barcode") + return $"
{EscapeHtml(value)}
"; + if (t == "image") + return $""; + return EscapeHtml(value); + } + + private static string FormatColumnValue(object? raw, JsonNode? col, string? contentType) + { + if (raw == null) return string.Empty; + var t = (contentType ?? "text").Trim().ToLowerInvariant(); + if (t is "number" or "amount") + { + if (double.TryParse(raw.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out var d) + || double.TryParse(raw.ToString(), NumberStyles.Any, CultureInfo.CurrentCulture, out d)) + { + var decimals = Math.Clamp((int?)col?["decimalPlaces"]?.GetValue() ?? 2, 0, 6); + var formatted = d.ToString("N" + decimals, CultureInfo.CurrentCulture); + if (t == "amount") + { + var amountType = ReadAsString(col?["amountType"], "CNY"); + var symbol = amountType switch { "USD" => "$", "EUR" => "EUR ", _ => "¥" }; + return symbol + formatted; + } + return formatted; + } + } + return raw.ToString() ?? string.Empty; + } + + // ─── 多级表头构建 ─────────────────────────────────────────────────────── + + private sealed class HeaderCell + { + public int Row { get; set; } + public int Col { get; set; } + public int RowSpan { get; set; } = 1; + public int ColSpan { get; set; } = 1; + public string Title { get; set; } = string.Empty; + public string Align { get; set; } = "center"; + public double WidthPercent { get; set; } + } + + private static List> BuildTableHeaderRows(JsonNode tableEl, List columns) + { + var colCount = Math.Max(1, columns.Count); + var widths = columns.Select(c => c["width"]?.GetValue() ?? 30d).ToList(); + var totalWidth = Math.Max(0.001, widths.Sum()); + double CalcWP(int start, int span) + { + var end = Math.Min(colCount, start + span); + var sum = 0d; + for (var i = start; i < end; i++) sum += widths[i]; + return sum / totalWidth * 100d; + } + + var enableMulti = string.Equals(ReadAsString(tableEl["enableMultiHeader"], "false"), "true", StringComparison.OrdinalIgnoreCase); + if (!enableMulti) + { + var single = new List(); + for (var i = 0; i < colCount; i++) + single.Add(new HeaderCell + { + Row = 0, Col = i, + Title = ReadAsString(columns[i]["title"], string.Empty) ?? string.Empty, + Align = ReadAsString(columns[i]["align"], "center") ?? "center", + WidthPercent = CalcWP(i, 1) + }); + return new List> { single }; + } + + var cfg = tableEl["headerConfig"]; + var rowCount = Math.Max(1, cfg?["rowCount"]?.GetValue() ?? 1); + var cfgColCount = Math.Max(1, cfg?["colCount"]?.GetValue() ?? colCount); + if (cfgColCount != colCount) + return BuildTableHeaderRows(new JsonObject { ["enableMultiHeader"] = false }, columns); + + var owner = new HeaderCell?[rowCount, colCount]; + var cells = new List(); + var cfgCells = cfg?["cells"]?.AsArray() ?? new JsonArray(); + + foreach (var n in cfgCells.OfType()) + { + var r = Math.Max(0, n["row"]?.GetValue() ?? 0); + var c = Math.Max(0, n["col"]?.GetValue() ?? 0); + var rs = Math.Max(1, n["rowspan"]?.GetValue() ?? 1); + var cs = Math.Max(1, n["colspan"]?.GetValue() ?? 1); + if (r >= rowCount || c >= colCount) continue; + var maxR = Math.Min(rowCount, r + rs); + var maxC = Math.Min(colCount, c + cs); + var conflict = false; + for (var rr = r; rr < maxR && !conflict; rr++) + for (var cc = c; cc < maxC; cc++) + if (owner[rr, cc] != null) { conflict = true; break; } + if (conflict) continue; + var cell = new HeaderCell + { + Row = r, Col = c, RowSpan = maxR - r, ColSpan = maxC - c, + Title = ReadAsString(n["title"], string.Empty) ?? string.Empty, + Align = ReadAsString(n["align"], "center") ?? "center", + }; + cell.WidthPercent = CalcWP(cell.Col, cell.ColSpan); + cells.Add(cell); + for (var rr = r; rr < maxR; rr++) + for (var cc = c; cc < maxC; cc++) + owner[rr, cc] = cell; + } + + for (var r = 0; r < rowCount; r++) + { + for (var c = 0; c < colCount; c++) + { + if (owner[r, c] != null) continue; + var cell = new HeaderCell + { + Row = r, Col = c, RowSpan = 1, ColSpan = 1, + Title = r == rowCount - 1 ? (ReadAsString(columns[c]["title"], string.Empty) ?? string.Empty) : string.Empty, + Align = ReadAsString(columns[c]["align"], "center") ?? "center", + WidthPercent = CalcWP(c, 1) + }; + owner[r, c] = cell; + cells.Add(cell); + } + } + + var rows = Enumerable.Range(0, rowCount).Select(_ => new List()).ToList(); + foreach (var cell in cells) + { + if (!ReferenceEquals(owner[cell.Row, cell.Col], cell)) continue; + rows[cell.Row].Add(cell); + } + foreach (var row in rows) row.Sort((a, b) => a.Col.CompareTo(b.Col)); + return rows; + } + + // ─── 通用工具 ─────────────────────────────────────────────────────────── + + private static object? ResolveField(JsonObject data, string fieldPath) + { + JsonNode? node = data; + foreach (var key in fieldPath.Split('.')) + { + node = node?[key]; + if (node == null) return null; + } + return node switch + { + JsonArray a => a, + JsonObject o => o, + JsonValue v when v.TryGetValue(out var s) => s, + JsonValue v when v.TryGetValue(out var d) => d, + JsonValue v when v.TryGetValue(out var b) => b, + _ => node?.ToString() + }; + } + + private static string? ReadAsString(JsonNode? node, string? defaultValue = null) + { + if (node == null) return defaultValue; + if (node is JsonValue v) + { + if (v.TryGetValue(out var s)) return s; + if (v.TryGetValue(out var d)) return d.ToString(CultureInfo.InvariantCulture); + if (v.TryGetValue(out var i)) return i.ToString(CultureInfo.InvariantCulture); + if (v.TryGetValue(out var l)) return l.ToString(CultureInfo.InvariantCulture); + if (v.TryGetValue(out var b)) return b ? "true" : "false"; + } + return node.ToString(); + } + + private static string EscapeHtml(string? s) => + (s ?? string.Empty).Replace("&", "&").Replace("<", "<").Replace(">", ">").Replace("\"", """); + + private static string ConvertDateFormat(string? jsFormat) => + (jsFormat ?? "yyyy-MM-dd").Replace("YYYY", "yyyy").Replace("DD", "dd"); + + private static string ReplaceTemplatePlaceholders(string input, JsonObject data, int pageNo, int totalPages) + { + if (string.IsNullOrEmpty(input)) return input; + return Regex.Replace(input, @"\{\{\s*([\w\.]+)\s*\}\}", m => + { + var key = m.Groups[1].Value; + if (key.Equals("pageNo", StringComparison.OrdinalIgnoreCase)) return pageNo.ToString(CultureInfo.InvariantCulture); + if (key.Equals("totalPages", StringComparison.OrdinalIgnoreCase)) return totalPages.ToString(CultureInfo.InvariantCulture); + return ResolveField(data, key)?.ToString() ?? string.Empty; + }); + } + + private static string RemoveCssProperty(string style, string propertyName) + { + if (string.IsNullOrWhiteSpace(style)) return string.Empty; + var kept = style.Split(';', StringSplitOptions.RemoveEmptyEntries) + .Select(p => p.Trim()) + .Where(p => + { + var idx = p.IndexOf(':'); + if (idx <= 0) return true; + return !p[..idx].Trim().Equals(propertyName, StringComparison.OrdinalIgnoreCase); + }) + .ToList(); + return kept.Count == 0 ? string.Empty : string.Join(";", kept) + ";"; + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotService.cs b/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotService.cs new file mode 100644 index 0000000..518b232 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotService.cs @@ -0,0 +1,109 @@ +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Configuration; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Print; + +/// +/// PrintDot 本地桥接器 WebSocket 客户端。 +/// URL 从 appsettings.json 的 PrintDot:Url 读取,可在运行时通过 覆盖。 +/// +public class PrintDotService : IPrintDotService +{ + private readonly IConfiguration _config; + private static readonly JsonSerializerOptions JsonOpts = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + + public PrintDotService(IConfiguration config) + { + _config = config; + } + + private string ResolveWsUrl() + { + var url = PrintDotSettings.Current?.WsUrl + ?? _config.GetValue("PrintDot:Url") + ?? "ws://127.0.0.1:1122/ws"; + return url.TrimEnd('/'); + } + + public async Task> GetPrintersAsync(CancellationToken ct = default) + { + var wsUrl = ResolveWsUrl(); + using var ws = new ClientWebSocket(); + await ws.ConnectAsync(new Uri(wsUrl), ct); + + // 连接后服务端立即推送 printer_list + var json = await ReceiveTextAsync(ws, ct); + var doc = JsonNode.Parse(json); + if (doc?["type"]?.GetValue() == "printer_list") + { + var arr = doc["data"]?.AsArray(); + if (arr != null) + { + return arr + .Where(n => n != null) + .Select(n => new PrintDotPrinter( + Name: n!["name"]?.GetValue()?.Trim() ?? string.Empty, + IsDefault: n["isDefault"]?.GetValue() ?? false)) + .Where(p => !string.IsNullOrWhiteSpace(p.Name)) + .ToList(); + } + } + return []; + } + + public async Task PrintAsync(string printerName, string pdfBase64, string jobName = "QH-MES", int copies = 1, CancellationToken ct = default) + { + // 去掉 data: 前缀 + var content = pdfBase64.Trim(); + var comma = content.IndexOf(','); + if (content.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma >= 0) + content = content[(comma + 1)..]; + + var payload = new + { + printer = printerName, + content, + job = new { name = jobName, copies = Math.Max(1, copies) } + }; + + var wsUrl = ResolveWsUrl(); + using var ws = new ClientWebSocket(); + await ws.ConnectAsync(new Uri(wsUrl), ct); + + // 先等服务端推送 printer_list 再发任务 + await ReceiveTextAsync(ws, ct); + + var msg = JsonSerializer.Serialize(payload, JsonOpts); + await ws.SendAsync(Encoding.UTF8.GetBytes(msg), WebSocketMessageType.Text, true, ct); + + // 等待打印结果(最多 3 分钟) + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(TimeSpan.FromMinutes(3)); + while (ws.State == WebSocketState.Open) + { + var response = await ReceiveTextAsync(ws, cts.Token); + var resDoc = JsonNode.Parse(response); + var status = resDoc?["status"]?.GetValue(); + if (status == null) continue; + if (status == "success") return; + throw new InvalidOperationException(resDoc?["message"]?.GetValue() ?? "PrintDot 打印失败"); + } + } + + private static async Task ReceiveTextAsync(ClientWebSocket ws, CancellationToken ct) + { + var buffer = new ArraySegment(new byte[64 * 1024]); + using var ms = new System.IO.MemoryStream(); + WebSocketReceiveResult result; + do + { + result = await ws.ReceiveAsync(buffer, ct); + ms.Write(buffer.Array!, buffer.Offset, result.Count); + } while (!result.EndOfMessage); + return Encoding.UTF8.GetString(ms.ToArray()); + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotSettings.cs b/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotSettings.cs new file mode 100644 index 0000000..4a687d2 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Print/PrintDotSettings.cs @@ -0,0 +1,46 @@ +namespace YY.Admin.Services.Service.Print; + +/// +/// 运行时可修改的 PrintDot 连接设置(由设置页写入,PrintDotService 优先读取)。 +/// +public class PrintDotSettings +{ + private static readonly string SettingsPath = System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "YY.Admin", "print-dot-settings.json"); + + public string WsUrl { get; set; } = "ws://127.0.0.1:1122/ws"; + public string SelectedPrinter { get; set; } = string.Empty; + + private static PrintDotSettings? _current; + public static PrintDotSettings? Current => _current; + + public static PrintDotSettings Load() + { + try + { + if (System.IO.File.Exists(SettingsPath)) + { + var json = System.IO.File.ReadAllText(SettingsPath); + _current = System.Text.Json.JsonSerializer.Deserialize(json) ?? new PrintDotSettings(); + return _current; + } + } + catch { /* 读取失败使用默认值 */ } + _current = new PrintDotSettings(); + return _current; + } + + public void Save() + { + try + { + var dir = System.IO.Path.GetDirectoryName(SettingsPath)!; + System.IO.Directory.CreateDirectory(dir); + var json = System.Text.Json.JsonSerializer.Serialize(this, new System.Text.Json.JsonSerializerOptions { WriteIndented = true }); + System.IO.File.WriteAllText(SettingsPath, json); + _current = this; + } + catch { /* 忽略保存失败 */ } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/PrintTemplateService.cs b/yy-admin-master/YY.Admin.Services/Service/Print/PrintTemplateService.cs new file mode 100644 index 0000000..175d383 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Print/PrintTemplateService.cs @@ -0,0 +1,170 @@ +using System.IO; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Configuration; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Print; + +public class PrintTemplateService : IPrintTemplateService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly object _cacheLock = new(); + private List _localCache = new(); + private readonly string _cacheFilePath; + + private static readonly JsonSerializerOptions JsonOpts = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + Converters = { new NullableDateTimeJsonConverter() } + }; + + private string BaseUrl => + (_configuration.GetValue("JeecgIntegration:BaseUrl") ?? "http://localhost:8080/jeecg-boot").TrimEnd('/'); + + public PrintTemplateService(IHttpClientFactory httpClientFactory, IConfiguration configuration) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + + var appDataDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "YY.Admin", "sync-cache"); + Directory.CreateDirectory(appDataDir); + _cacheFilePath = Path.Combine(appDataDir, "print-template-cache.json"); + + LoadCacheFromDisk(); + } + + public IReadOnlyList GetCached() + { + lock (_cacheLock) + { + return _localCache.AsReadOnly(); + } + } + + public async Task GetByCodeAsync(string templateCode, CancellationToken ct = default) + { + try + { + var client = _httpClientFactory.CreateClient("JeecgApi"); + var url = $"{BaseUrl}/print/template/anon/queryByCode?code={Uri.EscapeDataString(templateCode)}"; + var response = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return GetCachedByCode(templateCode); + var result = await response.Content.ReadFromJsonAsync>(JsonOpts, ct).ConfigureAwait(false); + return result?.Success == true ? result.Result : GetCachedByCode(templateCode); + } + catch + { + return GetCachedByCode(templateCode); + } + } + + public async Task> ListAsync(CancellationToken ct = default) + { + try + { + return await RefreshCacheAsync(ct).ConfigureAwait(false); + } + catch + { + return GetCached(); + } + } + + public async Task> RefreshCacheAsync(CancellationToken ct = default) + { + var client = _httpClientFactory.CreateClient("JeecgApi"); + var url = $"{BaseUrl}/print/template/anon/list?pageSize=200"; + var response = await client.GetAsync(url, ct).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + var result = await response.Content.ReadFromJsonAsync>>(JsonOpts, ct).ConfigureAwait(false); + if (result?.Success != true) + throw new InvalidOperationException(result?.Message ?? "后端返回失败"); + + var records = result.Result?.Records ?? new List(); + lock (_cacheLock) + { + _localCache = records; + SaveCacheToDiskUnsafe(); + } + return records.AsReadOnly(); + } + + private PrintTemplate? GetCachedByCode(string code) + { + lock (_cacheLock) + { + return _localCache.FirstOrDefault(t => + string.Equals(t.TemplateCode, code, StringComparison.OrdinalIgnoreCase)); + } + } + + private void LoadCacheFromDisk() + { + try + { + if (!File.Exists(_cacheFilePath)) return; + var json = File.ReadAllText(_cacheFilePath); + var data = JsonSerializer.Deserialize>(json, JsonOpts); + _localCache = data ?? new List(); + } + catch + { + _localCache = new List(); + } + } + + private void SaveCacheToDiskUnsafe() + { + try + { + var json = JsonSerializer.Serialize(_localCache, JsonOpts); + File.WriteAllText(_cacheFilePath, json); + } + catch { } + } + + private record JeecgResult(bool Success, T? Result, string? Message); + private record JeecgPage(List Records, long Total); + + private sealed class NullableDateTimeJsonConverter : JsonConverter + { + private static readonly string[] Formats = + [ + "yyyy-MM-dd HH:mm:ss", + "yyyy-MM-dd HH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ss", + "yyyy-MM-ddTHH:mm:ss.fff", + "yyyy-MM-ddTHH:mm:ssZ", + "yyyy-MM-ddTHH:mm:ss.fffZ" + ]; + + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) return null; + if (reader.TokenType == JsonTokenType.String) + { + var raw = reader.GetString(); + if (string.IsNullOrWhiteSpace(raw)) return null; + if (DateTime.TryParseExact(raw, Formats, System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.AssumeLocal, out var exact)) + return exact; + if (DateTime.TryParse(raw, out var fallback)) return fallback; + } + throw new JsonException($"无法将 JSON 值转换为 DateTime?,token={reader.TokenType}"); + } + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + if (value.HasValue) writer.WriteStringValue(value.Value.ToString("yyyy-MM-dd HH:mm:ss")); + else writer.WriteNullValue(); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/PrintTemplateSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Print/PrintTemplateSyncCoordinator.cs new file mode 100644 index 0000000..262882d --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Print/PrintTemplateSyncCoordinator.cs @@ -0,0 +1,81 @@ +using Prism.Events; +using System.Text.Json; +using YY.Admin.Core; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Print; + +public class PrintTemplateSyncCoordinator : ISingletonDependency +{ + private readonly IEventAggregator _eventAggregator; + private readonly IPrintTemplateService _printTemplateService; + private readonly ILoggerService _logger; + private SubscriptionToken? _remoteCommandToken; + private SubscriptionToken? _networkStatusToken; + + public PrintTemplateSyncCoordinator( + IEventAggregator eventAggregator, + IPrintTemplateService printTemplateService, + SyncPollManager pollManager, + ILoggerService logger) + { + _eventAggregator = eventAggregator; + _printTemplateService = printTemplateService; + _logger = logger; + + _remoteCommandToken = _eventAggregator + .GetEvent() + .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); + _networkStatusToken = _eventAggregator + .GetEvent() + .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + + pollManager.Register("打印模板", () => + { + _eventAggregator.GetEvent() + .Publish(new PrintTemplateChangedPayload { Action = "poll" }); + return Task.CompletedTask; + }); + + _logger.Information("[打印模板推送] PrintTemplateSyncCoordinator 已启动"); + } + + private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (!payload.IsOnline) return; + _logger.Information("[打印模板推送] 网络恢复,触发补偿刷新"); + _eventAggregator.GetEvent() + .Publish(new PrintTemplateChangedPayload { Action = "reconnect" }); + } + + private void OnRemoteCommand(RemoteCommandPayload payload) + { + try + { + var json = payload.CommandJson ?? string.Empty; + if (string.IsNullOrWhiteSpace(json)) return; + + using var doc = JsonDocument.Parse(json); + if (!doc.RootElement.TryGetProperty("cmd", out var cmdEl)) return; + var cmd = cmdEl.GetString() ?? string.Empty; + if (!cmd.Equals("PRINT_TEMPLATE_CHANGED", StringComparison.OrdinalIgnoreCase)) return; + + doc.RootElement.TryGetProperty("action", out var actionEl); + doc.RootElement.TryGetProperty("templateId", out var idEl); + + var changedPayload = new PrintTemplateChangedPayload + { + Action = actionEl.GetString() ?? string.Empty, + TemplateId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null + }; + + _logger.Information($"[打印模板推送] 收到变更信号 action={changedPayload.Action}, templateId={changedPayload.TemplateId}"); + _eventAggregator.GetEvent().Publish(changedPayload); + } + catch (Exception ex) + { + _logger.Warning($"[打印模板推送] 处理 STOMP 信号失败: {ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj b/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj index 20e209c..b39cadf 100644 --- a/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj +++ b/yy-admin-master/YY.Admin.Services/YY.Admin.Services.csproj @@ -20,6 +20,7 @@ + diff --git a/yy-admin-master/YY.Admin/App.xaml.cs b/yy-admin-master/YY.Admin/App.xaml.cs index a7a9afc..2345a90 100644 --- a/yy-admin-master/YY.Admin/App.xaml.cs +++ b/yy-admin-master/YY.Admin/App.xaml.cs @@ -10,6 +10,7 @@ using YY.Admin.EventBus; using YY.Admin.Filter; using YY.Admin.Module; using YY.Admin.Properties; +using YY.Admin.Services.Service.Print; using YY.Admin.Setup; using YY.Admin.ViewModels; using YY.Admin.Views; @@ -96,6 +97,9 @@ namespace YY.Admin _logger.Information("应用程序已启动"); + // 加载 PrintDot 本地设置(使 PrintDotService 在任何页面调用前已有配置) + PrintDotSettings.Load(); + // 启动断联续传同步模块 _syncModule.OnInitialized(Container); } diff --git a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs index b5ce524..147fb02 100644 --- a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs +++ b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs @@ -162,6 +162,10 @@ public class StompWebSocketService : ISignalRService await SendFrameAsync( BuildSubscribeFrame("sub-mes-warehouse-areas", "/topic/sync/mes-warehouse-areas"), cancellationToken).ConfigureAwait(false); + // 打印模板变更:订阅 /topic/sync/print-templates + await SendFrameAsync( + BuildSubscribeFrame("sub-print-templates", "/topic/sync/print-templates"), + cancellationToken).ConfigureAwait(false); // 订阅服务端 PONG 回复(应用层假在线检测) await SendFrameAsync( diff --git a/yy-admin-master/YY.Admin/Infrastructure/Print/HtmlToPdfRenderer.cs b/yy-admin-master/YY.Admin/Infrastructure/Print/HtmlToPdfRenderer.cs new file mode 100644 index 0000000..d2edf37 --- /dev/null +++ b/yy-admin-master/YY.Admin/Infrastructure/Print/HtmlToPdfRenderer.cs @@ -0,0 +1,93 @@ +using System.IO; +using System.Text; +using System.Windows; +using Microsoft.Web.WebView2.Core; +using Microsoft.Web.WebView2.Wpf; + +namespace YY.Admin.Infrastructure.Print; + +/// +/// 使用隐藏 WebView2 窗口将 HTML 字符串渲染为 PDF base64。 +/// 所有调用必须最终在 WPF UI 线程执行,由内部 Dispatcher 保证。 +/// +public static class HtmlToPdfRenderer +{ + public static Task RenderAsync(string html, double widthMm = 210, double heightMm = 297) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Application.Current.Dispatcher.InvokeAsync(async () => + { + try + { + var result = await RenderOnUiThreadAsync(html, widthMm, heightMm); + tcs.SetResult(result); + } + catch (Exception ex) + { + tcs.SetException(ex); + } + }); + + return tcs.Task; + } + + private static async Task RenderOnUiThreadAsync(string html, double widthMm, double heightMm) + { + var tempHtml = Path.ChangeExtension(Path.GetTempFileName(), ".html"); + var tempPdf = Path.ChangeExtension(Path.GetTempFileName(), ".pdf"); + Window? win = null; + + try + { + File.WriteAllText(tempHtml, html, Encoding.UTF8); + + var env = await CoreWebView2Environment.CreateAsync(); + var wv = new WebView2(); + + win = new Window + { + Width = 1, + Height = 1, + Left = -99999, + Top = -99999, + ShowInTaskbar = false, + WindowStyle = WindowStyle.None, + Visibility = Visibility.Visible + }; + win.Content = wv; + win.Show(); + + await wv.EnsureCoreWebView2Async(env); + + var navTcs = new TaskCompletionSource(); + wv.CoreWebView2.NavigationCompleted += (_, _) => navTcs.TrySetResult(); + wv.CoreWebView2.Navigate(new Uri(tempHtml).AbsoluteUri); + await navTcs.Task; + + // 短暂等待 JS/CSS 完成渲染 + await Task.Delay(200); + + var settings = env.CreatePrintSettings(); + settings.PageWidth = widthMm / 25.4; + settings.PageHeight = heightMm / 25.4; + settings.MarginTop = 0; + settings.MarginBottom = 0; + settings.MarginLeft = 0; + settings.MarginRight = 0; + settings.ShouldPrintHeaderAndFooter = false; + settings.ShouldPrintBackgrounds = true; + + await wv.CoreWebView2.PrintToPdfAsync(tempPdf, settings); + + var pdfBytes = File.ReadAllBytes(tempPdf); + return Convert.ToBase64String(pdfBytes); + } + finally + { + win?.Close(); + try { File.Delete(tempHtml); } catch { } + try { File.Delete(tempPdf); } catch { } + } + } +} diff --git a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs index 481d369..c50aec2 100644 --- a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs +++ b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs @@ -16,6 +16,7 @@ using YY.Admin.Views.WeightRecord; using YY.Admin.Views.RawMaterialCard; using YY.Admin.Views.WarehouseArea; using YY.Admin.Views.RawMaterialEntry; +using YY.Admin.Views.Print; namespace YY.Admin { @@ -81,6 +82,10 @@ namespace YY.Admin containerRegistry.RegisterForNavigation(); // 库区管理 containerRegistry.RegisterForNavigation(); + // 打印设置 + containerRegistry.RegisterForNavigation(); + // 打印模板列表 + containerRegistry.RegisterForNavigation(); } } public class DialogWindow : Window, IDialogWindow diff --git a/yy-admin-master/YY.Admin/Module/SyncModule.cs b/yy-admin-master/YY.Admin/Module/SyncModule.cs index 4cb5d24..1f3316b 100644 --- a/yy-admin-master/YY.Admin/Module/SyncModule.cs +++ b/yy-admin-master/YY.Admin/Module/SyncModule.cs @@ -25,6 +25,7 @@ using YY.Admin.Services.Service.Vehicle; using YY.Admin.Services.Service.Warehouse; using YY.Admin.Services.Service.WarehouseArea; using YY.Admin.Services.Service.WeightRecord; +using YY.Admin.Services.Service.Print; namespace YY.Admin.Module; @@ -74,6 +75,10 @@ public class SyncModule : IModule containerRegistry.RegisterSingleton(); // 统一轮询管理器(修改 SyncPollManager.PollInterval 即可调整所有模块的轮询间隔) containerRegistry.RegisterSingleton(); + // 打印服务:PrintDot 桥接器 + 打印模板(含 STOMP 实时同步 + 本地缓存) + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient(); @@ -140,6 +145,8 @@ public class SyncModule : IModule _ = containerProvider.Resolve(); // 强制实例化数据字典同步协调器 _ = containerProvider.Resolve(); + // 强制实例化打印模板同步协调器 + _ = containerProvider.Resolve(); } private static IAsyncPolicy GetRetryPolicy() diff --git a/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs index d0a8473..6fcbf95 100644 --- a/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs @@ -140,7 +140,18 @@ namespace YY.Admin.ViewModels.Control // 已实现页面:库区管理 ["WarehouseAreaListView"] = "WarehouseAreaListView", ["/xslmes/mesXslWarehouseArea"] = "WarehouseAreaListView", - ["mesXslWarehouseArea"] = "WarehouseAreaListView" + ["mesXslWarehouseArea"] = "WarehouseAreaListView", + + // 已实现页面:打印设置 + ["PrintSettingsView"] = "PrintSettingsView", + ["/system/printSettings"] = "PrintSettingsView", + ["printSettings"] = "PrintSettingsView", + + // 已实现页面:打印模板 + ["PrintTemplateListView"] = "PrintTemplateListView", + ["/platform/print"] = "PrintTemplateListView", + ["print"] = "PrintTemplateListView", + ["printTemplate"] = "PrintTemplateListView" }; private MenuItem? _selectedMenuItem; diff --git a/yy-admin-master/YY.Admin/ViewModels/Print/PrintSettingsViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Print/PrintSettingsViewModel.cs new file mode 100644 index 0000000..80c75f3 --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Print/PrintSettingsViewModel.cs @@ -0,0 +1,142 @@ +using System.Collections.ObjectModel; +using System.Windows; +using Prism.Commands; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service.Print; +using YY.Admin.Core; + +namespace YY.Admin.ViewModels.Print; + +public class PrintSettingsViewModel : BaseViewModel +{ + private readonly IPrintDotService _printDotService; + private readonly IPrintTemplateService _printTemplateService; + + // ── 连接设置 ────────────────────────────────────────────────────────── + + private string _wsUrl = "ws://127.0.0.1:1122/ws"; + public string WsUrl + { + get => _wsUrl; + set => SetProperty(ref _wsUrl, value); + } + + // ── 打印机列表 ────────────────────────────────────────────────────────── + + public ObservableCollection Printers { get; } = new(); + + private PrintDotPrinter? _selectedPrinter; + public PrintDotPrinter? SelectedPrinter + { + get => _selectedPrinter; + set => SetProperty(ref _selectedPrinter, value); + } + + // ── 模板列表 ──────────────────────────────────────────────────────────── + + public ObservableCollection Templates { get; } = new(); + + // ── 状态 ──────────────────────────────────────────────────────────────── + + private bool _isBusy; + public bool IsBusy + { + get => _isBusy; + set => SetProperty(ref _isBusy, value); + } + + private string _statusMessage = string.Empty; + public string StatusMessage + { + get => _statusMessage; + set => SetProperty(ref _statusMessage, value); + } + + // ── 命令 ──────────────────────────────────────────────────────────────── + + public DelegateCommand TestConnectionCommand { get; } + public DelegateCommand SaveCommand { get; } + public DelegateCommand RefreshTemplatesCommand { get; } + + public PrintSettingsViewModel( + IPrintDotService printDotService, + IPrintTemplateService printTemplateService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _printDotService = printDotService; + _printTemplateService = printTemplateService; + + TestConnectionCommand = new DelegateCommand(async () => await TestConnectionAsync()); + SaveCommand = new DelegateCommand(SaveSettings); + RefreshTemplatesCommand = new DelegateCommand(async () => await RefreshTemplatesAsync()); + + // 加载已保存的设置 + var saved = PrintDotSettings.Load(); + WsUrl = saved.WsUrl; + if (!string.IsNullOrWhiteSpace(saved.SelectedPrinter)) + _selectedPrinter = new PrintDotPrinter(saved.SelectedPrinter, false); + } + + private async Task TestConnectionAsync() + { + IsBusy = true; + StatusMessage = "正在连接..."; + Printers.Clear(); + try + { + // 临时用当前输入的 URL 测试 + PrintDotSettings.Current!.WsUrl = WsUrl; + var list = await _printDotService.GetPrintersAsync(); + foreach (var p in list) Printers.Add(p); + + // 还原保存的已选打印机 + var saved = PrintDotSettings.Load(); + var match = list.FirstOrDefault(p => p.Name == saved.SelectedPrinter); + SelectedPrinter = match ?? (list.Count > 0 ? list[0] : null); + + StatusMessage = $"连接成功,共 {list.Count} 台打印机"; + } + catch (Exception ex) + { + StatusMessage = $"连接失败:{ex.Message}"; + } + finally + { + IsBusy = false; + } + } + + private void SaveSettings() + { + var settings = new PrintDotSettings + { + WsUrl = WsUrl.Trim(), + SelectedPrinter = SelectedPrinter?.Name ?? string.Empty + }; + settings.Save(); + StatusMessage = "设置已保存"; + MessageBox.Show("PrintDot 设置已保存", "保存成功", MessageBoxButton.OK, MessageBoxImage.Information); + } + + private async Task RefreshTemplatesAsync() + { + IsBusy = true; + Templates.Clear(); + try + { + var list = await _printTemplateService.ListAsync(); + foreach (var t in list) Templates.Add(t); + StatusMessage = $"已加载 {list.Count} 个打印模板"; + } + catch (Exception ex) + { + StatusMessage = $"加载模板失败:{ex.Message}"; + } + finally + { + IsBusy = false; + } + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/Print/PrintTemplateListViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Print/PrintTemplateListViewModel.cs new file mode 100644 index 0000000..71669bf --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Print/PrintTemplateListViewModel.cs @@ -0,0 +1,201 @@ +using System.Collections.ObjectModel; +using System.Windows; +using Prism.Commands; +using Prism.Events; +using YY.Admin.Core; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Events; +using YY.Admin.Core.Services; +using YY.Admin.Services.Service.Print; +using YY.Admin.Views.Print; + +namespace YY.Admin.ViewModels.Print; + +public class PrintTemplateListViewModel : BaseViewModel +{ + private readonly IPrintTemplateService _printTemplateService; + private readonly IEventAggregator _eventAggregator; + private SubscriptionToken? _changeToken; + + private List _allTemplates = new(); + + public ObservableCollection Templates { get; } = new(); + + private string _statusMessage = string.Empty; + public string StatusMessage + { + get => _statusMessage; + set => SetProperty(ref _statusMessage, value); + } + + private string? _filterCode; + public string? FilterCode + { + get => _filterCode; + set => SetProperty(ref _filterCode, value); + } + + private string? _filterName; + public string? FilterName + { + get => _filterName; + set => SetProperty(ref _filterName, value); + } + + private string? _filterCategory; + public string? FilterCategory + { + get => _filterCategory; + set => SetProperty(ref _filterCategory, value); + } + + public DelegateCommand SearchCommand { get; } + public DelegateCommand ResetCommand { get; } + public DelegateCommand PreviewCommand { get; } + + public PrintTemplateListViewModel( + IPrintTemplateService printTemplateService, + IEventAggregator eventAggregator, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _printTemplateService = printTemplateService; + _eventAggregator = eventAggregator; + + SearchCommand = new DelegateCommand(ApplyFilter); + PreviewCommand = new DelegateCommand(ShowPreview); + ResetCommand = new DelegateCommand(() => + { + FilterCode = null; + FilterName = null; + FilterCategory = null; + ApplyFilter(); + }); + + _changeToken = _eventAggregator + .GetEvent() + .Subscribe(_ => { RefreshSilentlyAsync().ConfigureAwait(false); }, ThreadOption.UIThread); + + // 先用缓存立即填充,再后台静默刷新 + ShowCached(); + _ = RefreshSilentlyAsync(); + } + + private void ShowCached() + { + var cached = _printTemplateService.GetCached(); + if (cached.Count == 0) return; + _allTemplates = cached.ToList(); + ApplyFilter(); + UpdateStatus(); + } + + private async Task RefreshSilentlyAsync() + { + try + { + var list = await _printTemplateService.RefreshCacheAsync().ConfigureAwait(false); + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + _allTemplates = list.ToList(); + ApplyFilter(); + UpdateStatus(); + }); + } + catch + { + // 静默失败:保留当前缓存内容不动,不显示错误 + var cached = _printTemplateService.GetCached(); + if (cached.Count > 0 && _allTemplates.Count == 0) + { + System.Windows.Application.Current.Dispatcher.Invoke(() => + { + _allTemplates = cached.ToList(); + ApplyFilter(); + UpdateStatus(); + }); + } + } + } + + private void ApplyFilter() + { + IEnumerable result = _allTemplates; + + if (!string.IsNullOrWhiteSpace(FilterCode)) + result = result.Where(t => (t.TemplateCode ?? string.Empty) + .Contains(FilterCode, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(FilterName)) + result = result.Where(t => (t.TemplateName ?? string.Empty) + .Contains(FilterName, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(FilterCategory)) + result = result.Where(t => (t.Category ?? string.Empty) + .Contains(FilterCategory, StringComparison.OrdinalIgnoreCase)); + + var filtered = result.ToList(); + + // 原地差量更新,避免滚动位置重置和闪烁 + for (int i = Templates.Count - 1; i >= 0; i--) + { + if (!filtered.Any(t => t.Id == Templates[i].Id)) + Templates.RemoveAt(i); + } + for (int i = 0; i < filtered.Count; i++) + { + var item = filtered[i]; + var existingIdx = -1; + for (int j = 0; j < Templates.Count; j++) + { + if (Templates[j].Id == item.Id) { existingIdx = j; break; } + } + if (existingIdx < 0) + Templates.Insert(i, item); + else + { + if (existingIdx != i) Templates.Move(existingIdx, i); + Templates[i] = item; + } + } + } + + private void UpdateStatus() + { + var hasFilter = !string.IsNullOrWhiteSpace(FilterCode) + || !string.IsNullOrWhiteSpace(FilterName) + || !string.IsNullOrWhiteSpace(FilterCategory); + StatusMessage = hasFilter + ? $"筛选结果 {Templates.Count} / {_allTemplates.Count} 个" + : _allTemplates.Count > 0 + ? $"共 {_allTemplates.Count} 个模板" + : "暂无模板"; + } + + private void ShowPreview(PrintTemplate template) + { + if (template == null) return; + ShowPreviewAsync(template); + } + + private async Task ShowPreviewAsync(PrintTemplate template) + { + // 列表缓存可能不含 templateJson(大字段),按需通过 queryByCode 单独拉取 + var json = template.TemplateJson; + if (string.IsNullOrWhiteSpace(json) || json == "{}") + { + try + { + var full = await _printTemplateService.GetByCodeAsync(template.TemplateCode ?? ""); + json = full?.TemplateJson; + } + catch { /* 保持 json 为 null,预览窗口显示"尚未设计" */ } + } + + var win = new PrintPreviewWindow(template, json) + { + Owner = Application.Current.MainWindow + }; + win.Show(); + } +} diff --git a/yy-admin-master/YY.Admin/Views/Print/PrintPreviewWindow.xaml b/yy-admin-master/YY.Admin/Views/Print/PrintPreviewWindow.xaml new file mode 100644 index 0000000..f5d7af1 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/Print/PrintPreviewWindow.xaml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +