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 8ae20968..ace9fb25 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 @@ -215,6 +215,7 @@ public class ShiroConfig { filterChainDefinitionMap.put("/mes/material/mixerMaterial/anon/**", "anon"); // 打印模板免密接口(供桌面端调用) filterChainDefinitionMap.put("/print/template/anon/**", "anon"); + filterChainDefinitionMap.put("/print/bizTemplateBind/anon/**", "anon"); // 系统分类字典免密接口(供桌面端调用) filterChainDefinitionMap.put("/sys/category/anon/**", "anon"); // 桌面端用户反同步批量上报(Outbox -> /sys/sync/batch) diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java index 0067ff9d..c69aeafb 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslDesktopAnonController.java @@ -636,6 +636,7 @@ public class MesXslDesktopAnonController { ArrayNode mapping = PrintBizDataMappingUtil.parseMappingArray(bind.getFieldMappingJson()); JsonNode bizRoot = objectMapper.valueToTree(card); ObjectNode printData = PrintBizDataMappingUtil.mapBizToPrintData(bizRoot, mapping); + PrintBizDataMappingUtil.fillMissingDataBindingParamKeys(printData, tpl.getTemplateJson()); Map out = new HashMap<>(8); out.put("cardId", card.getId()); out.put("templateCode", bind.getTemplateCode()); @@ -662,6 +663,7 @@ public class MesXslDesktopAnonController { ArrayNode mapping = PrintBizDataMappingUtil.parseMappingArray(bind.getFieldMappingJson()); JsonNode bizRoot = objectMapper.valueToTree(entry); ObjectNode printData = PrintBizDataMappingUtil.mapBizToPrintData(bizRoot, mapping); + PrintBizDataMappingUtil.fillMissingDataBindingParamKeys(printData, tpl.getTemplateJson()); Map out = new HashMap<>(8); out.put("entryId", entry.getId()); out.put("templateCode", bind.getTemplateCode()); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialCardController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialCardController.java index 1b38b30b..f784d150 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialCardController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialCardController.java @@ -194,6 +194,7 @@ public class MesXslRawMaterialCardController extends JeecgController out = new HashMap<>(8); out.put("cardId", card.getId()); out.put("templateCode", bind.getTemplateCode()); diff --git a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java index 6d02a002..a50d57bc 100644 --- a/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java +++ b/jeecg-boot/jeecg-boot-module/jeecg-module-xslmes/src/main/java/org/jeecg/modules/xslmes/controller/MesXslRawMaterialEntryController.java @@ -180,6 +180,7 @@ public class MesXslRawMaterialEntryController extends JeecgController out = new HashMap<>(8); out.put("entryId", entry.getId()); out.put("templateCode", bind.getTemplateCode()); diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintBizTemplateBindController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintBizTemplateBindController.java index 231c0d3b..5fd7402b 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintBizTemplateBindController.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/controller/PrintBizTemplateBindController.java @@ -9,12 +9,14 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import com.alibaba.fastjson.JSON; import jakarta.servlet.http.HttpServletRequest; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.Data; +import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.apache.shiro.authz.annotation.RequiresPermissions; import org.jeecg.common.api.vo.Result; @@ -36,11 +38,13 @@ import org.jeecg.modules.print.vo.PrintBizFieldItemVO; import org.jeecg.modules.print.vo.PrintBizTypeVO; import org.jeecg.modules.print.vo.PrintTemplateFieldItemVO; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.messaging.simp.SimpMessagingTemplate; import org.springframework.web.bind.annotation.*; /** * 业务与打印模板绑定:可视化配置字段映射 */ +@Slf4j @Tag(name = "业务打印绑定") @RestController @RequestMapping("/print/bizTemplateBind") @@ -51,6 +55,7 @@ public class PrintBizTemplateBindController extends JeecgController delete(@RequestParam(name = "id") String id) { - service.removeById(id); + if (service.removeById(id)) { + publishPrintBizTemplateBindChanged("delete", id); + } return Result.OK("删除成功"); } @@ -239,6 +248,10 @@ public class PrintBizTemplateBindController extends JeecgController res = new HashMap<>(4); res.put("templateCode", bind.getTemplateCode()); res.put("templateId", bind.getTemplateId()); @@ -308,4 +321,43 @@ public class PrintBizTemplateBindController extends JeecgController permIds; } + + // ═══════════════════════════ 桌面端免密只读 ═══════════════════════════ + + @Operation(summary = "业务打印绑定-免密分页列表(桌面端缓存同步)") + @GetMapping("/anon/list") + public Result> anonList( + PrintBizTemplateBind 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.orderByDesc("create_time"); + Page page = new Page<>(pageNo, pageSize); + return Result.OK(service.page(page, qw)); + } + + @Operation(summary = "业务打印绑定-免密按 id 查询(桌面端)") + @GetMapping("/anon/queryById") + public Result anonQueryById(@RequestParam(name = "id") String id) { + PrintBizTemplateBind row = service.getById(id); + return row != null ? Result.OK(row) : Result.error("未找到记录"); + } + + /** + * 广播到 /topic/sync/print-biz-binds,与桌面端 PrintBizTemplateBindSyncCoordinator 对应。 + */ + private void publishPrintBizTemplateBindChanged(String action, String bindId) { + try { + Map event = new HashMap<>(); + event.put("cmd", "PRINT_BIZ_TEMPLATE_BIND_CHANGED"); + event.put("action", action); + event.put("bindId", bindId); + event.put("timestamp", System.currentTimeMillis()); + messagingTemplate.convertAndSend("/topic/sync/print-biz-binds", JSON.toJSONString(event)); + } catch (Exception e) { + log.debug("广播 STOMP 事件失败 [PRINT_BIZ_TEMPLATE_BIND_CHANGED]: {}", e.getMessage()); + } + } } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/util/PrintBizDataMappingUtil.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/util/PrintBizDataMappingUtil.java index 4b94e59c..b1a320f4 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/util/PrintBizDataMappingUtil.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/print/util/PrintBizDataMappingUtil.java @@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import java.util.List; import org.apache.commons.lang3.StringUtils; +import org.jeecg.modules.print.vo.PrintTemplateFieldItemVO; /** 按映射规则把业务 JSON 转为模板打印数据(键为模板 bindField) */ public final class PrintBizDataMappingUtil { @@ -24,15 +26,75 @@ public final class PrintBizDataMappingUtil { } String templateField = text(rule, "templateField"); String bizField = text(rule, "bizField"); - if (StringUtils.isAnyBlank(templateField, bizField)) { + // 仅要求模板字段名;业务字段为空表示「不参与取数」,仍向 printData 写入空字符串,避免模板占位符缺键 + if (StringUtils.isBlank(templateField)) { continue; } - JsonNode val = resolvePath(bizRoot, bizField); + JsonNode val; + if (StringUtils.isBlank(bizField)) { + val = MAPPER.getNodeFactory().textNode(""); + } else { + val = resolvePath(bizRoot, bizField); + } setPath(printData, templateField, val); } return printData; } + /** + * 按模板中已声明的绑定路径({@code dataBinding.params}、画布/表格等元素的 {@code bindField},与 + * {@link PrintNativeTemplateFieldExtractor} 一致),向 printData 补齐缺失路径(空字符串)。 + * + *

避免字段映射未包含某键时 API 缺键,桌面端渲染把「设计稿占位 text」当成数据显示。 + */ + public static ObjectNode fillMissingDataBindingParamKeys(ObjectNode printData, String templateJson) { + if (printData == null) { + printData = MAPPER.createObjectNode(); + } + if (StringUtils.isBlank(templateJson)) { + return printData; + } + try { + List fields = PrintNativeTemplateFieldExtractor.extract(templateJson); + for (PrintTemplateFieldItemVO item : fields) { + if (item == null || StringUtils.isBlank(item.getBindField())) { + continue; + } + String bf = item.getBindField().trim(); + if (!hasPath(printData, bf)) { + setPath(printData, bf, MAPPER.getNodeFactory().textNode("")); + } + } + } catch (Exception ignored) { + // 模板解析异常时不阻断打印 + } + return printData; + } + + /** 判断 printData 上是否存在该点分路径(含嵌套对象) */ + private static boolean hasPath(ObjectNode root, String path) { + if (StringUtils.isBlank(path)) { + return false; + } + String[] parts = path.split("\\."); + JsonNode cur = root; + for (int i = 0; i < parts.length; i++) { + if (cur == null || !cur.isObject()) { + return false; + } + ObjectNode obj = (ObjectNode) cur; + String p = parts[i]; + if (p.isEmpty() || !obj.has(p)) { + return false; + } + if (i == parts.length - 1) { + return true; + } + cur = obj.get(p); + } + return false; + } + private static JsonNode resolvePath(JsonNode root, String path) { if (root == null || StringUtils.isBlank(path)) { return null; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/java/org/jeecg/JeecgSystemApplication.java b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/java/org/jeecg/JeecgSystemApplication.java index 33706800..90b270c6 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/java/org/jeecg/JeecgSystemApplication.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/java/org/jeecg/JeecgSystemApplication.java @@ -48,6 +48,7 @@ public class JeecgSystemApplication extends SpringBootServletInitializer { String port = env.getProperty("server.port"); String path = oConvertUtils.getString(env.getProperty("server.servlet.context-path")); log.info("\n----------------------------------------------------------\n\t" + + "Application Jeecg-Boot is running! Access URLs:\n\t" + "Local: \t\thttp://localhost:" + port + path + "\n\t" + "External: \thttp://" + ip + ":" + port + path + "/doc.html\n\t" + diff --git a/jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue b/jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue index 919a24b9..abf9032e 100644 --- a/jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue +++ b/jeecgboot-vue3/src/views/print/bizTemplateBind/index.vue @@ -129,6 +129,7 @@ > 同名自动匹配 + 添加空占位 @@ -590,14 +591,35 @@ function rebuildMappingRows() { const saved = unref(savedMappingRef); - mappingRows.value = unref(tplFields).map((t) => { - const templateField = t.bindField; + const tplList = unref(tplFields); + const tplByField = new Map(tplList.map((t) => [t.bindField, t])); + + // 先按已保存 JSON 的顺序收录键(含「模板未再声明」的占位如 Parameter3 + 空 bizField),避免仅依赖解析结果而丢行 + const orderedKeys: string[] = []; + const seen = new Set(); + for (const s of saved) { + const k = (s.templateField || '').trim(); + if (!k || seen.has(k)) continue; + seen.add(k); + orderedKeys.push(k); + } + for (const t of tplList) { + const k = (t.bindField || '').trim(); + if (!k || seen.has(k)) continue; + seen.add(k); + orderedKeys.push(k); + } + + mappingRows.value = orderedKeys.map((templateField) => { + const t = tplByField.get(templateField); const hit = saved.find((x) => x.templateField === templateField); return { templateField, - bizField: hit?.bizField, - elementType: t.elementType, - titleHint: t.titleHint, + bizField: hit?.bizField ?? '', + elementType: t?.elementType || 'param', + titleHint: + t?.titleHint || + (!t ? '已保存映射(当前模板 JSON 未声明该占位,仍可按空业务字段输出)' : ''), }; }); } @@ -615,10 +637,26 @@ mappingRows.value = [...unref(mappingRows)]; } + /** 手动增加仅输出空值的模板占位(写入 savedMappingRef 并重建行,避免再次「解析模板」时丢失) */ + function addPlaceholderParamRow() { + const raw = window.prompt( + '请输入模板参数 bindField(如 Parameter3)。业务字段将固定为空字符串,不参与业务 JSON 取值。', + 'Parameter3', + ); + const k = (raw || '').trim(); + if (!k) return; + if (unref(savedMappingRef).some((x) => x.templateField === k) || unref(mappingRows).some((r) => r.templateField === k)) { + createMessage.warning('该占位已存在'); + return; + } + savedMappingRef.value = [...unref(savedMappingRef), { templateField: k, bizField: '' }]; + rebuildMappingRows(); + } + function buildFieldMappingJson() { const arr = unref(mappingRows) - .filter((r) => r.bizField) - .map((r) => ({ templateField: r.templateField, bizField: r.bizField })); + .filter((r) => r.templateField) + .map((r) => ({ templateField: r.templateField, bizField: r.bizField ?? '' })); return JSON.stringify(arr); } diff --git a/yy-admin-master/YY.Admin.Core/Core/Events/PrintBizTemplateBindChangedEvent.cs b/yy-admin-master/YY.Admin.Core/Core/Events/PrintBizTemplateBindChangedEvent.cs new file mode 100644 index 00000000..b4770d52 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Events/PrintBizTemplateBindChangedEvent.cs @@ -0,0 +1,11 @@ +using Prism.Events; + +namespace YY.Admin.Core.Events; + +public class PrintBizTemplateBindChangedPayload +{ + public string Action { get; set; } = string.Empty; + public string? BindId { get; set; } +} + +public class PrintBizTemplateBindChangedEvent : PubSubEvent { } diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/IPrintBizTemplateBindService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/IPrintBizTemplateBindService.cs new file mode 100644 index 00000000..00acfa2d --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Core/Services/IPrintBizTemplateBindService.cs @@ -0,0 +1,12 @@ +using YY.Admin.Core.Entity; + +namespace YY.Admin.Core.Services; + +///

业务打印绑定:免密拉取 + 本地缓存(只读) +public interface IPrintBizTemplateBindService +{ + IReadOnlyList GetCached(); + Task> ListAsync(CancellationToken ct = default); + Task> RefreshCacheAsync(CancellationToken ct = default); + Task GetByIdAsync(string id, CancellationToken ct = default); +} diff --git a/yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs b/yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs index 0828a981..5ef98820 100644 --- a/yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs +++ b/yy-admin-master/YY.Admin.Core/Core/Services/IRawMaterialEntryService.cs @@ -24,6 +24,9 @@ public interface IRawMaterialEntryService /// 调用后端接口生成条码/批次号(格式:QH+物料编码+yyMMdd+序号) Task GenerateBarcodeAsync(string materialCode, CancellationToken ct = default); + /// 按业务打印绑定准备模板 JSON 与 printData(与后端 prepareNativePrint 一致,免密 anon)。 + Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default); + /// /// 同步读取本地缓存的「全量入场记录」快照(深拷贝),不会触发远端拉取。 /// 主要用于「磅单已入场重量」等跨表实时聚合,且需要保持与后端相同口径的场景。 diff --git a/yy-admin-master/YY.Admin.Core/Entity/PrintBizTemplateBind.cs b/yy-admin-master/YY.Admin.Core/Entity/PrintBizTemplateBind.cs new file mode 100644 index 00000000..8ed4da53 --- /dev/null +++ b/yy-admin-master/YY.Admin.Core/Entity/PrintBizTemplateBind.cs @@ -0,0 +1,19 @@ +namespace YY.Admin.Core.Entity; + +/// 业务打印绑定(print_biz_template_bind,桌面端只读缓存) +public class PrintBizTemplateBind +{ + public string? Id { get; set; } + /// 业务编码(菜单 permission id) + public string? BizCode { get; set; } + public string? BizName { get; set; } + public string? TemplateId { get; set; } + public string? TemplateCode { get; set; } + /// 字段映射 JSON + public string? FieldMappingJson { 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 2495a185..9623ab4d 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysMenuSeedData.cs @@ -223,6 +223,9 @@ public class SysMenuSeedData : ISqlSugarEntitySeedData new SysMenu{ Id=1300300110401, Pid=1300300110101, Title="增加", Permission="sysPrint:add", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, new SysMenu{ Id=1300300110501, Pid=1300300110101, Title="删除", Permission="sysPrint:delete", Type=MenuTypeEnum.Btn, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=100 }, + // 业务打印绑定(桌面端只读列表;Web 端可在「打印模板」模块维护) + new SysMenu{ Id=1300300110701, Pid=1300300000101, Title="业务打印绑定", Path="/platform/printBizBind", Name="printBizBind", Component="PrintBizTemplateBindListView", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=195 }, + // 系统配置 new SysMenu{ Id=1300300140101, Pid=1300300000101, Title="系统配置", Path="/platform/infoSetting", Name="sysInfoSetting", Component="/system/infoSetting/index", Icon="", Type=MenuTypeEnum.Menu, CreateTime=DateTime.Parse("2022-02-10 00:00:00"), OrderNo=220 }, #endregion diff --git a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs index efa571ed..b6eaecb7 100644 --- a/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs +++ b/yy-admin-master/YY.Admin.Core/SeedData/SysTenantMenuSeedData.cs @@ -117,6 +117,7 @@ public class SysTenantMenuSeedData : ISqlSugarEntitySeedData new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200050401 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300300010501 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300300110501 }, + new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300300110701 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200080401 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300200020501 }, new SysTenantMenu(){ TenantId=1300000000001,MenuId=1300300120501 }, diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs b/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs index 25aca516..a23a203b 100644 --- a/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/Print/NativePrintRenderService.cs @@ -244,8 +244,10 @@ public static class NativePrintRenderService 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"]); + var type = ReadAsString(el["type"], "text"); + // 设计器默认 bindField 为空字符串 "",须视为「未绑定」,否则误走数据分支导致标题等只剩空串 + var bindFieldRaw = ReadAsString(el["bindField"]); + var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim(); string text; if (type == "date") @@ -258,7 +260,8 @@ public static class NativePrintRenderService } else if (bindField != null) { - text = ResolveField(data, bindField)?.ToString() ?? ReadAsString(el["text"], string.Empty); + // 已绑定数据字段:缺键或解析不到时留空,不回退到画布上的设计占位 text(否则会误显示「采购订单」等) + text = ResolveField(data, bindField)?.ToString() ?? string.Empty; } else { @@ -291,10 +294,11 @@ public static class NativePrintRenderService 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); + var bindFieldRaw = ReadAsString(el["bindField"]); + var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim(); + var value = ReadAsString(el["value"], string.Empty); if (bindField != null) - value = ResolveField(data, bindField)?.ToString() ?? value; + value = ResolveField(data, bindField)?.ToString() ?? string.Empty; if (string.IsNullOrWhiteSpace(value)) return string.Empty; string inner; @@ -332,10 +336,11 @@ public static class NativePrintRenderService 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); + var bindFieldRaw = ReadAsString(el["bindField"]); + var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim(); + var value = ReadAsString(el["value"], string.Empty); if (bindField != null) - value = ResolveField(data, bindField)?.ToString() ?? value; + value = ResolveField(data, bindField)?.ToString() ?? string.Empty; if (string.IsNullOrWhiteSpace(value)) return string.Empty; // 从元素配置取格式/显示文字开关;元素未设时按 Code128 + 显示文字默认 @@ -714,10 +719,11 @@ public static class NativePrintRenderService 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); + var bindFieldRaw = ReadAsString(el["bindField"]); + var bindField = string.IsNullOrWhiteSpace(bindFieldRaw) ? null : bindFieldRaw.Trim(); + var src = ReadAsString(el["src"], string.Empty); if (bindField != null) - src = ResolveField(data, bindField)?.ToString() ?? src; + src = ResolveField(data, bindField)?.ToString() ?? string.Empty; var fit = ReadAsString(el["fit"], "contain"); var objFit = fit switch { "fill" => "fill", "cover" => "cover", _ => "contain" }; var inner = $""; @@ -1147,7 +1153,7 @@ public static class NativePrintRenderService // 解析原始值 string rawValue; if (!string.IsNullOrWhiteSpace(cell.BindField)) - rawValue = ResolveField(data, cell.BindField!)?.ToString() ?? cell.Text; + rawValue = ResolveField(data, cell.BindField!)?.ToString() ?? string.Empty; else rawValue = cell.Text; diff --git a/yy-admin-master/YY.Admin.Services/Service/Print/PrintBizTemplateBindService.cs b/yy-admin-master/YY.Admin.Services/Service/Print/PrintBizTemplateBindService.cs new file mode 100644 index 00000000..c681de76 --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Print/PrintBizTemplateBindService.cs @@ -0,0 +1,172 @@ +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 Microsoft.Extensions.Http; +using YY.Admin.Core.Entity; +using YY.Admin.Core.Services; + +namespace YY.Admin.Services.Service.Print; + +/// 业务打印绑定:免密列表 + 本地缓存(只读) +public class PrintBizTemplateBindService : IPrintBizTemplateBindService +{ + 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 PrintBizTemplateBindService(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-biz-bind-cache.json"); + + LoadCacheFromDisk(); + } + + public IReadOnlyList GetCached() + { + lock (_cacheLock) + { + return _localCache.AsReadOnly(); + } + } + + 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/bizTemplateBind/anon/list?pageSize=500&pageNo=1"; + 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(); + } + + public async Task GetByIdAsync(string id, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(id)) return null; + try + { + var client = _httpClientFactory.CreateClient("JeecgApi"); + var url = $"{BaseUrl}/print/bizTemplateBind/anon/queryById?id={Uri.EscapeDataString(id)}"; + var response = await client.GetAsync(url, ct).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) return GetCachedById(id); + var result = await response.Content.ReadFromJsonAsync>(JsonOpts, ct).ConfigureAwait(false); + return result?.Success == true ? result.Result : GetCachedById(id); + } + catch + { + return GetCachedById(id); + } + } + + private PrintBizTemplateBind? GetCachedById(string id) + { + lock (_cacheLock) + { + return _localCache.FirstOrDefault(x => string.Equals(x.Id, id, 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/PrintBizTemplateBindSyncCoordinator.cs b/yy-admin-master/YY.Admin.Services/Service/Print/PrintBizTemplateBindSyncCoordinator.cs new file mode 100644 index 00000000..e2a4544a --- /dev/null +++ b/yy-admin-master/YY.Admin.Services/Service/Print/PrintBizTemplateBindSyncCoordinator.cs @@ -0,0 +1,77 @@ +using Prism.Events; +using System.Text.Json; +using YY.Admin.Core; +using YY.Admin.Core.Events; +namespace YY.Admin.Services.Service.Print; + +/// 订阅 STOMP:PRINT_BIZ_TEMPLATE_BIND_CHANGED,驱动列表静默刷新;网络恢复补偿。 +public class PrintBizTemplateBindSyncCoordinator : ISingletonDependency +{ + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + private SubscriptionToken? _remoteCommandToken; + private SubscriptionToken? _networkStatusToken; + + public PrintBizTemplateBindSyncCoordinator( + IEventAggregator eventAggregator, + SyncPollManager pollManager, + ILoggerService logger) + { + _eventAggregator = eventAggregator; + _logger = logger; + + _remoteCommandToken = _eventAggregator + .GetEvent() + .Subscribe(OnRemoteCommand, ThreadOption.BackgroundThread); + _networkStatusToken = _eventAggregator + .GetEvent() + .Subscribe(OnNetworkStatusChanged, ThreadOption.BackgroundThread); + + pollManager.Register("业务打印绑定", () => + { + _eventAggregator.GetEvent() + .Publish(new PrintBizTemplateBindChangedPayload { Action = "poll" }); + return Task.CompletedTask; + }); + + _logger.Information("[业务打印绑定推送] PrintBizTemplateBindSyncCoordinator 已启动"); + } + + private void OnNetworkStatusChanged(NetworkStatusChangedPayload payload) + { + if (!payload.IsOnline) return; + _logger.Information("[业务打印绑定推送] 网络恢复,触发补偿刷新"); + _eventAggregator.GetEvent() + .Publish(new PrintBizTemplateBindChangedPayload { 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_BIZ_TEMPLATE_BIND_CHANGED", StringComparison.OrdinalIgnoreCase)) return; + + doc.RootElement.TryGetProperty("action", out var actionEl); + doc.RootElement.TryGetProperty("bindId", out var idEl); + + var changedPayload = new PrintBizTemplateBindChangedPayload + { + Action = actionEl.GetString() ?? string.Empty, + BindId = idEl.ValueKind == JsonValueKind.String ? idEl.GetString() : null + }; + + _logger.Information($"[业务打印绑定推送] 收到变更 action={changedPayload.Action}, bindId={changedPayload.BindId}"); + _eventAggregator.GetEvent().Publish(changedPayload); + } + catch (Exception ex) + { + _logger.Warning($"[业务打印绑定推送] 处理 STOMP 信号失败: {ex.Message}"); + } + } +} diff --git a/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs index 0a361627..f8836a04 100644 --- a/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs +++ b/yy-admin-master/YY.Admin.Services/Service/RawMaterialEntry/RawMaterialEntryService.cs @@ -237,6 +237,39 @@ public class RawMaterialEntryService : IRawMaterialEntryService, ISingletonDepen return null; } + public async Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default) + { + if (!_networkMonitor.IsOnline) + return (string.Empty, "{}", "当前离线,无法获取打印数据"); + try + { + var url = $"{BaseUrl}/xslmes/mesXslRawMaterialEntry/anon/prepareNativePrint?id={Uri.EscapeDataString(id)}&tenantId={DefaultTenantId}"; + using var client = CreateClient(); + var resp = await client.GetAsync(url, ct).ConfigureAwait(false); + var json = await resp.Content.ReadAsStringAsync(ct).ConfigureAwait(false); + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + if (!root.TryGetProperty("code", out var codeEl) || codeEl.GetInt32() != 200) + { + var msg = root.TryGetProperty("message", out var msgEl) ? msgEl.GetString() : "未知错误"; + return (string.Empty, "{}", msg ?? "服务端返回错误"); + } + var result = root.GetProperty("result"); + var templateJson = result.TryGetProperty("templateJson", out var tjEl) ? tjEl.GetString() : null; + var printDataJson = result.TryGetProperty("printData", out var pdEl) + ? pdEl.GetRawText() + : "{}"; + if (string.IsNullOrWhiteSpace(templateJson)) + return (string.Empty, "{}", "服务端未返回模板 JSON,请先在「业务打印绑定」中配置原料入场记录"); + return (templateJson!, printDataJson, null); + } + catch (Exception ex) + { + _logger.Warning($"[原料入场] 准备打印数据失败 id={id}: {ex.Message}"); + return (string.Empty, "{}", $"获取打印数据失败:{ex.Message}"); + } + } + public IReadOnlyList GetCachedSnapshot() { // 注意:不允许直接返回 _localCache 引用,避免外部修改污染缓存;用 Clone 做深拷贝。 diff --git a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs index 147fb02e..1d88d43a 100644 --- a/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs +++ b/yy-admin-master/YY.Admin/Infrastructure/Hubs/StompWebSocketService.cs @@ -166,6 +166,10 @@ public class StompWebSocketService : ISignalRService await SendFrameAsync( BuildSubscribeFrame("sub-print-templates", "/topic/sync/print-templates"), cancellationToken).ConfigureAwait(false); + // 业务打印绑定变更:订阅 /topic/sync/print-biz-binds + await SendFrameAsync( + BuildSubscribeFrame("sub-print-biz-binds", "/topic/sync/print-biz-binds"), + cancellationToken).ConfigureAwait(false); // 订阅服务端 PONG 回复(应用层假在线检测) await SendFrameAsync( diff --git a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs index c50aec22..540358c2 100644 --- a/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs +++ b/yy-admin-master/YY.Admin/Module/NavigationExtensions.cs @@ -86,6 +86,8 @@ namespace YY.Admin 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 1f3316be..cc7f5580 100644 --- a/yy-admin-master/YY.Admin/Module/SyncModule.cs +++ b/yy-admin-master/YY.Admin/Module/SyncModule.cs @@ -78,7 +78,9 @@ public class SyncModule : IModule // 打印服务:PrintDot 桥接器 + 打印模板(含 STOMP 实时同步 + 本地缓存) containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); var serviceCollection = new ServiceCollection(); serviceCollection.AddTransient(); @@ -147,6 +149,7 @@ 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 6fcbf957..93808926 100644 --- a/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/Control/MenuTreeViewModel.cs @@ -151,7 +151,12 @@ namespace YY.Admin.ViewModels.Control ["PrintTemplateListView"] = "PrintTemplateListView", ["/platform/print"] = "PrintTemplateListView", ["print"] = "PrintTemplateListView", - ["printTemplate"] = "PrintTemplateListView" + ["printTemplate"] = "PrintTemplateListView", + + // 已实现页面:业务打印绑定(桌面端) + ["PrintBizTemplateBindListView"] = "PrintBizTemplateBindListView", + ["/platform/printBizBind"] = "PrintBizTemplateBindListView", + ["printBizBind"] = "PrintBizTemplateBindListView" }; private MenuItem? _selectedMenuItem; diff --git a/yy-admin-master/YY.Admin/ViewModels/Print/PrintBizTemplateBindListViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/Print/PrintBizTemplateBindListViewModel.cs new file mode 100644 index 00000000..544452ca --- /dev/null +++ b/yy-admin-master/YY.Admin/ViewModels/Print/PrintBizTemplateBindListViewModel.cs @@ -0,0 +1,192 @@ +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.Views.Print; + +namespace YY.Admin.ViewModels.Print; + +/// 业务打印绑定列表(只读,本地缓存 + 同步) +public class PrintBizTemplateBindListViewModel : BaseViewModel +{ + private readonly IPrintBizTemplateBindService _bindService; + private SubscriptionToken? _changeToken; + + private List _all = new(); + + public ObservableCollection Items { get; } = new(); + + private string _statusMessage = string.Empty; + public string StatusMessage + { + get => _statusMessage; + set => SetProperty(ref _statusMessage, value); + } + + private string? _filterBizCode; + public string? FilterBizCode + { + get => _filterBizCode; + set => SetProperty(ref _filterBizCode, value); + } + + private string? _filterBizName; + public string? FilterBizName + { + get => _filterBizName; + set => SetProperty(ref _filterBizName, value); + } + + private string? _filterTemplateCode; + public string? FilterTemplateCode + { + get => _filterTemplateCode; + set => SetProperty(ref _filterTemplateCode, value); + } + + public DelegateCommand SearchCommand { get; } + public DelegateCommand ResetCommand { get; } + public DelegateCommand DetailCommand { get; } + + public PrintBizTemplateBindListViewModel( + IPrintBizTemplateBindService bindService, + IContainerExtension container, + IRegionManager regionManager) : base(container, regionManager) + { + _bindService = bindService; + + SearchCommand = new DelegateCommand(ApplyFilter); + ResetCommand = new DelegateCommand(() => + { + FilterBizCode = null; + FilterBizName = null; + FilterTemplateCode = null; + ApplyFilter(); + }); + DetailCommand = new DelegateCommand(OpenDetail); + + _changeToken = _eventAggregator + .GetEvent() + .Subscribe(_ => { RefreshSilentlyAsync().ConfigureAwait(false); }, ThreadOption.UIThread); + + ShowCached(); + _ = RefreshSilentlyAsync(); + } + + private void ShowCached() + { + var cached = _bindService.GetCached(); + if (cached.Count == 0) return; + _all = cached.ToList(); + ApplyFilter(); + UpdateStatus(); + } + + private async Task RefreshSilentlyAsync() + { + try + { + var list = await _bindService.RefreshCacheAsync().ConfigureAwait(false); + await Application.Current.Dispatcher.InvokeAsync(() => + { + _all = list.ToList(); + ApplyFilter(); + UpdateStatus(); + }); + } + catch + { + var cached = _bindService.GetCached(); + if (cached.Count > 0 && _all.Count == 0) + { + await Application.Current.Dispatcher.InvokeAsync(() => + { + _all = cached.ToList(); + ApplyFilter(); + UpdateStatus(); + }); + } + } + } + + private void ApplyFilter() + { + IEnumerable result = _all; + + if (!string.IsNullOrWhiteSpace(FilterBizCode)) + result = result.Where(x => (x.BizCode ?? string.Empty) + .Contains(FilterBizCode, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(FilterBizName)) + result = result.Where(x => (x.BizName ?? string.Empty) + .Contains(FilterBizName, StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrWhiteSpace(FilterTemplateCode)) + result = result.Where(x => (x.TemplateCode ?? string.Empty) + .Contains(FilterTemplateCode, StringComparison.OrdinalIgnoreCase)); + + var filtered = result.ToList(); + + for (int i = Items.Count - 1; i >= 0; i--) + { + if (!filtered.Any(t => t.Id == Items[i].Id)) + Items.RemoveAt(i); + } + for (int i = 0; i < filtered.Count; i++) + { + var item = filtered[i]; + var existingIdx = -1; + for (int j = 0; j < Items.Count; j++) + { + if (Items[j].Id == item.Id) { existingIdx = j; break; } + } + if (existingIdx < 0) + Items.Insert(i, item); + else + { + if (existingIdx != i) Items.Move(existingIdx, i); + Items[i] = item; + } + } + } + + private void UpdateStatus() + { + var hasFilter = !string.IsNullOrWhiteSpace(FilterBizCode) + || !string.IsNullOrWhiteSpace(FilterBizName) + || !string.IsNullOrWhiteSpace(FilterTemplateCode); + StatusMessage = hasFilter + ? $"筛选结果 {Items.Count} / {_all.Count} 条" + : _all.Count > 0 + ? $"共 {_all.Count} 条绑定(缓存于本地,可离线查看)" + : "暂无数据(联网后将自动同步)"; + } + + private async void OpenDetail(PrintBizTemplateBind? row) + { + if (row == null) return; + PrintBizTemplateBind model = row; + try + { + var fresh = await _bindService.GetByIdAsync(row.Id ?? "").ConfigureAwait(false); + if (fresh != null) model = fresh; + } + catch { /* 使用列表行数据 */ } + + // 网络请求 continuation 可能在非 STA 线程,Window 必须在 UI 线程创建 + var app = Application.Current; + if (app?.Dispatcher == null) return; + await app.Dispatcher.InvokeAsync(() => + { + var win = new PrintBizTemplateBindDetailWindow(model) + { + Owner = app.MainWindow + }; + win.ShowDialog(); + }); + } +} diff --git a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs index af9b3aec..0e8b31c5 100644 --- a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryEditDialogViewModel.cs @@ -366,44 +366,56 @@ public class RawMaterialEntryEditDialogViewModel : BaseViewModel, IDialogResulta AddSplitDetailCommand.RaiseCanExecuteChanged(); } + /// 校验并提交新增/编辑;不弹关闭、不执行独立页的 InitializeForAdd。成功时 Result=true。 + protected async Task PersistEntryCoreAsync() + { + if (Entry == null) return false; + ApplySplitDetailsToEntry(); + ApplyDefaultEntryStatusForAdd(); + ApplyHiddenFieldDefaultsForAdd(); + + var missing = new List(); + if (string.IsNullOrWhiteSpace(Entry.MaterialId)) missing.Add("密炼物料"); + if (string.IsNullOrWhiteSpace(Entry.BillNo)) missing.Add("榜单号"); + if (string.IsNullOrWhiteSpace(Entry.UnloadOperator)) missing.Add("卸货人"); + if (string.IsNullOrWhiteSpace(Entry.SupplierName)) missing.Add("供应商名称"); + if (missing.Count > 0) + { + HandyControl.Controls.MessageBox.Warning($"以下必填项不能为空:{string.Join("、", missing)}"); + return false; + } + + bool ok; + if (IsAddMode) + { + ok = await _entryService.AddAsync(Entry); + if (!ok) { HandyControl.Controls.MessageBox.Error("新增失败!"); return false; } + } + else + { + ok = await _entryService.EditAsync(Entry); + if (!ok) { HandyControl.Controls.MessageBox.Error("编辑失败!"); return false; } + } + Result = ok; + return true; + } + protected virtual async Task SaveAsync() { if (Entry == null) return; try { - ApplySplitDetailsToEntry(); - ApplyDefaultEntryStatusForAdd(); - ApplyHiddenFieldDefaultsForAdd(); + if (!await PersistEntryCoreAsync()) return; - var missing = new List(); - if (string.IsNullOrWhiteSpace(Entry.MaterialId)) missing.Add("密炼物料"); - if (string.IsNullOrWhiteSpace(Entry.BillNo)) missing.Add("榜单号"); - if (string.IsNullOrWhiteSpace(Entry.UnloadOperator)) missing.Add("卸货人"); - if (string.IsNullOrWhiteSpace(Entry.SupplierName)) missing.Add("供应商名称"); - if (missing.Count > 0) - { - HandyControl.Controls.MessageBox.Warning($"以下必填项不能为空:{string.Join("、", missing)}"); - return; - } - - bool ok; if (IsAddMode) { - ok = await _entryService.AddAsync(Entry); - if (ok) HandyControl.Controls.MessageBox.Success("新增成功!"); - else { HandyControl.Controls.MessageBox.Error("新增失败!"); return; } - } - else - { - ok = await _entryService.EditAsync(Entry); - if (!ok) { HandyControl.Controls.MessageBox.Error("编辑失败!"); return; } - } - Result = ok; - if (IsAddMode && CloseAction == null) - { - // 独立新增页面:保存成功后自动清空表单,便于连续录入 - InitializeForAdd(); - return; + HandyControl.Controls.MessageBox.Success("新增成功!"); + if (CloseAction == null) + { + // 独立新增页面:保存成功后自动清空表单,便于连续录入 + InitializeForAdd(); + return; + } } CloseAction?.Invoke(); diff --git a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs index 1d8d4985..7d6f117d 100644 --- a/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs +++ b/yy-admin-master/YY.Admin/ViewModels/RawMaterialEntry/RawMaterialEntryOperationViewModel.cs @@ -1,10 +1,16 @@ using Prism.Commands; using System.Collections.ObjectModel; using System.IO; +using System.Net; using System.Text.Json; +using System.Text.Json.Nodes; +using System.Windows; +using System.Windows.Threading; using YY.Admin.Core.Entity; using YY.Admin.Core.Services; +using YY.Admin.Infrastructure.Print; using YY.Admin.Services.Service; +using YY.Admin.Services.Service.Print; namespace YY.Admin.ViewModels.RawMaterialEntry; @@ -14,7 +20,12 @@ namespace YY.Admin.ViewModels.RawMaterialEntry; public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogViewModel { private const int TodayListFetchSize = 5000; + private const string RawMaterialEntryBizCode = "1900000000000000530"; + private const string RawMaterialEntryTemplateCode = "MES_RAW_MATERIAL_ENTRY"; private readonly IRawMaterialCardService _rawMaterialCardService; + private readonly IPrintDotService _printDotService; + private readonly IPrintBizTemplateBindService _printBizTemplateBindService; + private readonly IPrintTemplateService _printTemplateService; private static readonly JsonSerializerOptions LayoutJsonOpts = new() { @@ -95,25 +106,131 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView public DelegateCommand GenerateRawMaterialCardsCommand { get; } /// 「重新拆码」:清除已生成的卡片 + 清空明细,仅在编辑态可用。 public DelegateCommand ResplitCommand { get; } + public DelegateCommand SaveAndPrintCommand { get; } + public DelegateCommand RefreshPrintersCommand { get; } + + /// PrintDot 桥接器返回的打印机列表(与打印模板页一致)。 + public ObservableCollection 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 static readonly JsonSerializerOptions PreviewSnapshotJsonOpts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + private readonly DispatcherTimer _printPreviewTimer; + private string _lastPreviewSnapshot = string.Empty; + private int _previewRequestGeneration; + private bool _printPreviewBusy; + private bool _previewTemplateLoaded; + private string _previewTemplateJson = string.Empty; + private string _previewTemplateName = "原料入场记录"; + private string _previewTemplateCode = RawMaterialEntryTemplateCode; + private string _previewFieldMappingJson = "[]"; + + private bool _isPrintPreviewExpanded = true; + /// 右侧下方「入场标签打印预览」折叠面板是否展开。 + public bool IsPrintPreviewExpanded + { + get => _isPrintPreviewExpanded; + set + { + if (!SetProperty(ref _isPrintPreviewExpanded, value)) return; + if (value) _lastPreviewSnapshot = string.Empty; + } + } + + private string _printPreviewStatus = string.Empty; + /// 预览区状态提示(如离线、加载中、错误摘要)。 + public string PrintPreviewStatus + { + get => _printPreviewStatus; + private set => SetProperty(ref _printPreviewStatus, value); + } + + /// 由 View 订阅,在 UI 线程将 HTML 交给 WebView2。 + public event EventHandler? PrintPreviewHtmlReady; + + private bool _isActionBusy; + /// 底部动作按钮(保存/保存并打印/生成卡片)是否处于执行中。 + public bool IsActionBusy + { + get => _isActionBusy; + private set + { + if (SetProperty(ref _isActionBusy, value)) + { + RaisePropertyChanged(nameof(IsNotActionBusy)); + } + } + } + + public bool IsNotActionBusy => !IsActionBusy; + + private string _actionBusyText = "处理中..."; + /// 动作执行中的遮罩提示文案。 + public string ActionBusyText + { + get => _actionBusyText; + private set => SetProperty(ref _actionBusyText, value); + } public RawMaterialEntryOperationViewModel( IRawMaterialEntryService entryService, IJeecgDictSyncService dictSyncService, IMixerMaterialService mixerMaterialService, IRawMaterialCardService rawMaterialCardService, + IPrintDotService printDotService, + IPrintBizTemplateBindService printBizTemplateBindService, + IPrintTemplateService printTemplateService, IContainerExtension container, IRegionManager regionManager) : base(entryService, dictSyncService, mixerMaterialService, container, regionManager) { _rawMaterialCardService = rawMaterialCardService; + _printDotService = printDotService; + _printBizTemplateBindService = printBizTemplateBindService; + _printTemplateService = printTemplateService; LoadLayoutState(); ToggleRightPanelCommand = new DelegateCommand(() => IsRightPanelExpanded = !IsRightPanelExpanded); RefreshTodayEntriesCommand = new DelegateCommand(async () => await LoadTodayEntriesAsync()); GenerateRawMaterialCardsCommand = new DelegateCommand(async () => await GenerateRawMaterialCardsAsync()); ResplitCommand = new DelegateCommand(async () => await ResplitAsync(), () => CanResplit) .ObservesProperty(() => Entry); + SaveAndPrintCommand = new DelegateCommand(async () => await SaveAndPrintAsync()); + RefreshPrintersCommand = new DelegateCommand(async () => await RefreshPrintersAsync(verbose: true)); // 集合变化:批量重订阅 item.PropertyChanged 监听 HasCard/Portions,并同步刷新两个 Can*。 SplitCodeDetails.CollectionChanged += OnSplitCodeDetailsCollectionChangedForCanFlags; + _ = RefreshPrintersAsync(verbose: false); + + var dispatcher = Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + _printPreviewTimer = new DispatcherTimer( + TimeSpan.FromMilliseconds(480), + DispatcherPriority.Background, + (_, _) => _ = OnPrintPreviewTickAsync(), + dispatcher); + _printPreviewTimer.Start(); } /// @@ -160,6 +277,7 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView public override void InitializeForAdd() { + _lastPreviewSnapshot = string.Empty; _suppressTodaySelectionReaction = true; _selectedTodayEntry = null; RaisePropertyChanged(nameof(SelectedTodayEntry)); @@ -168,6 +286,149 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView RaisePropertyChanged(nameof(CanGenerateCards)); } + /// 保存后按业务打印绑定拉取模板与 printData,直接发送到 PrintDot 打印机(不弹预览窗)。 + private async Task SaveAndPrintAsync() + { + if (IsActionBusy) return; + if (Entry == null) return; + var wasEdit = !IsAddMode; + var barcode = Entry.Barcode; + BeginActionBusy("保存并打印中..."); + try + { + IsLoading = true; + if (!await PersistEntryCoreAsync()) return; + + string? entryId = Entry.Id; + if (string.IsNullOrWhiteSpace(entryId) && !string.IsNullOrWhiteSpace(barcode)) + { + var page = await EntryService.PageAsync(1, 50, barcode: barcode); + entryId = page.Records + .FirstOrDefault(e => string.Equals(e.Barcode, barcode, StringComparison.OrdinalIgnoreCase))?.Id + ?? page.Records + .OrderByDescending(e => e.EntryTime ?? e.CreateTime ?? DateTime.MinValue) + .FirstOrDefault()?.Id; + } + + if (string.IsNullOrWhiteSpace(entryId)) + { + HandyControl.Controls.MessageBox.Warning("保存成功,但未能解析记录主键,无法打印。请从右侧列表选中该条后再试。"); + } + else + { + var (templateJson, printDataJson, err) = await EntryService.PrepareNativePrintAsync(entryId); + if (!string.IsNullOrWhiteSpace(err)) + { + HandyControl.Controls.MessageBox.Error($"保存成功,但打印准备失败:{err}"); + } + else + { + var selectedPrinterName = SelectedPrinter?.Name?.Trim(); + if (string.IsNullOrWhiteSpace(selectedPrinterName)) + { + HandyControl.Controls.MessageBox.Warning("保存成功,但未选择打印机。请先选择打印机后再试。"); + } + else + { + JsonObject? dataObj = JsonNode.Parse(printDataJson) as JsonObject; + dataObj ??= new JsonObject(); + var html = NativePrintRenderService.RenderToHtml(templateJson, dataObj); + var tpl = BuildPrintTemplateForEntry(templateJson); + var pdfBase64 = await HtmlToPdfRenderer.RenderAsync( + html, + tpl.PaperWidthMm ?? 210d, + tpl.PaperHeightMm ?? 297d); + var jobName = string.IsNullOrWhiteSpace(barcode) ? "原料入场记录" : $"原料入场记录-{barcode}"; + await _printDotService.PrintAsync(selectedPrinterName, pdfBase64, jobName, 1); + } + } + } + + if (wasEdit) + HandyControl.Controls.MessageBox.Success("编辑成功!"); + else + HandyControl.Controls.MessageBox.Success("新增成功!"); + + Result = true; + RaisePropertyChanged(nameof(CanGenerateCards)); + await LoadTodayEntriesAsync(); + InitializeForAdd(); + } + catch (Exception ex) + { + HandyControl.Controls.MessageBox.Error($"保存或打印失败:{ex.Message}"); + } + finally + { + IsLoading = false; + EndActionBusy(); + } + } + + /// 通过 PrintDot 桥接器拉取打印机列表(与打印模板页一致)。 + private async Task RefreshPrintersAsync(bool verbose) + { + if (verbose) PrinterStatus = "刷新打印机中..."; + var savedName = PrintDotSettings.Load().SelectedPrinter; + try + { + var list = await _printDotService.GetPrintersAsync(); + 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) + { + Application.Current.Dispatcher.Invoke(() => + { + Printers.Clear(); + _suppressPrinterSave = true; + SelectedPrinter = null; + _suppressPrinterSave = false; + PrinterStatus = verbose + ? $"PrintDot 未连接:{ex.Message}" + : "PrintDot 未连接"; + }); + } + } + + private static PrintTemplate BuildPrintTemplateForEntry(string? templateJson) + { + try + { + if (string.IsNullOrWhiteSpace(templateJson) || templateJson == "{}") + return new PrintTemplate { TemplateName = "原料入场记录", TemplateCode = "MES_RAW_MATERIAL_ENTRY" }; + var root = JsonDocument.Parse(templateJson).RootElement; + double w = 210, h = 297; + if (root.TryGetProperty("page", out var page)) + { + if (page.TryGetProperty("width", out var wEl)) w = wEl.GetDouble(); + if (page.TryGetProperty("height", out var hEl)) h = hEl.GetDouble(); + } + return new PrintTemplate + { + TemplateName = "原料入场记录", + TemplateCode = "MES_RAW_MATERIAL_ENTRY", + PaperWidthMm = w, + PaperHeightMm = h, + PaperOrientation = w > h ? "横向" : "纵向", + }; + } + catch + { + return new PrintTemplate { TemplateName = "原料入场记录", TemplateCode = "MES_RAW_MATERIAL_ENTRY" }; + } + } + /// 页面首次加载时拉取「今日」列表(由 View Loaded 调用)。 public async Task LoadTodayEntriesOnFirstShowAsync() => await LoadTodayEntriesAsync(); @@ -232,6 +493,7 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView // 若物料列表此前未加载导致选中项未回填,则补一次拉取(与编辑弹窗逻辑一致) if (_pendingMaterialId != null) await LoadMaterialOptionsAsync(); + _lastPreviewSnapshot = string.Empty; } /// @@ -240,21 +502,30 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView /// protected override async Task SaveAsync() { + if (IsActionBusy) return; + BeginActionBusy("保存中..."); var wasEdit = !IsAddMode; - await base.SaveAsync(); - RaisePropertyChanged(nameof(CanGenerateCards)); - - if (!Result || CloseAction != null) + try { - return; + await base.SaveAsync(); + RaisePropertyChanged(nameof(CanGenerateCards)); + + if (!Result || CloseAction != null) + { + return; + } + + await LoadTodayEntriesAsync(); + + if (wasEdit) + { + HandyControl.Controls.MessageBox.Success("编辑成功!"); + InitializeForAdd(); + } } - - await LoadTodayEntriesAsync(); - - if (wasEdit) + finally { - HandyControl.Controls.MessageBox.Success("编辑成功!"); - InitializeForAdd(); + EndActionBusy(); } } @@ -348,6 +619,7 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView /// private async Task GenerateRawMaterialCardsAsync() { + if (IsActionBusy) return; if (Entry == null || string.IsNullOrWhiteSpace(Entry.Id)) { HandyControl.Controls.MessageBox.Warning("请先保存入场记录再生成原材料卡片!"); @@ -395,6 +667,7 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView } } + BeginActionBusy("生成中..."); try { IsLoading = true; @@ -481,9 +754,336 @@ public class RawMaterialEntryOperationViewModel : RawMaterialEntryEditDialogView finally { IsLoading = false; + EndActionBusy(); } } + private void BeginActionBusy(string text) + { + ActionBusyText = string.IsNullOrWhiteSpace(text) ? "处理中..." : text; + IsActionBusy = true; + } + + private void EndActionBusy() + { + IsActionBusy = false; + ActionBusyText = "处理中..."; + } + + /// 页面卸载时停止轮询,避免后台仍请求已释放的 WebView。 + public void StopPrintPreviewTimer() => _printPreviewTimer.Stop(); + + /// 视图重新附加时恢复轮询(与 配对)。 + public void StartPrintPreviewTimer() + { + if (!_printPreviewTimer.IsEnabled) _printPreviewTimer.Start(); + } + + private async Task OnPrintPreviewTickAsync() + { + if (!IsPrintPreviewExpanded || Entry == null || _printPreviewBusy) return; + try + { + ApplySplitDetailsToEntry(); + var snap = JsonSerializer.Serialize(Entry, PreviewSnapshotJsonOpts); + if (string.Equals(snap, _lastPreviewSnapshot, StringComparison.Ordinal)) return; + _lastPreviewSnapshot = snap; + _printPreviewBusy = true; + PrintPreviewStatus = "更新预览中…"; + var myGen = ++_previewRequestGeneration; + if (!await EnsureLocalPreviewTemplateLoadedAsync().ConfigureAwait(false)) + { + PostPrintPreviewHtml(BuildPrintPreviewPlaceholderHtml("未找到本地打印模板缓存,请先联网完成一次“业务打印绑定/打印模板”同步。")); + PrintPreviewStatus = "未找到本地模板缓存"; + return; + } + if (string.IsNullOrWhiteSpace(_previewTemplateJson) || _previewTemplateJson == "{}") + { + PostPrintPreviewHtml(BuildPrintPreviewPlaceholderHtml("尚未配置业务打印绑定或模板内容为空")); + PrintPreviewStatus = "未配置模板"; + return; + } + var dataObj = BuildLocalPreviewData(Entry); + if (myGen != _previewRequestGeneration) return; + string html; + try + { + html = NativePrintRenderService.RenderToHtml(_previewTemplateJson, dataObj); + } + catch (Exception ex) + { + html = BuildPrintPreviewErrorHtml(ex.Message); + PrintPreviewStatus = "渲染失败"; + PostPrintPreviewHtml(html); + return; + } + PostPrintPreviewHtml(html); + PrintPreviewStatus = string.Empty; + } + catch (Exception ex) + { + PostPrintPreviewHtml(BuildPrintPreviewErrorHtml(ex.Message)); + PrintPreviewStatus = "预览异常"; + } + finally + { + _printPreviewBusy = false; + } + } + + private void PostPrintPreviewHtml(string html) + { + var app = Application.Current; + if (app?.Dispatcher == null) + { + PrintPreviewHtmlReady?.Invoke(this, html); + return; + } + if (app.Dispatcher.CheckAccess()) PrintPreviewHtmlReady?.Invoke(this, html); + else app.Dispatcher.Invoke(() => PrintPreviewHtmlReady?.Invoke(this, html)); + } + + private static string BuildPrintPreviewErrorHtml(string message) + { + var esc = WebUtility.HtmlEncode(message ?? string.Empty); + return "
" + + "
⚠️
" + + "
预览:" + esc + "
" + + "
"; + } + + private static string BuildPrintPreviewPlaceholderHtml(string tip) + { + var esc = WebUtility.HtmlEncode(tip ?? string.Empty); + return "
" + + "
" + esc + "
"; + } + + private async Task EnsureLocalPreviewTemplateLoadedAsync() + { + if (_previewTemplateLoaded && !string.IsNullOrWhiteSpace(_previewTemplateJson)) + { + return true; + } + // 离线优先用本地缓存;若当前在线再尝试刷新缓存 + var bindList = _printBizTemplateBindService.GetCached(); + if (bindList.Count == 0) + { + try { bindList = await _printBizTemplateBindService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ } + } + var bind = bindList.FirstOrDefault(x => string.Equals(x.BizCode, RawMaterialEntryBizCode, StringComparison.OrdinalIgnoreCase)) + ?? bindList.FirstOrDefault(x => string.Equals(x.TemplateCode, RawMaterialEntryTemplateCode, StringComparison.OrdinalIgnoreCase)) + ?? bindList.FirstOrDefault(x => + (x.BizName ?? string.Empty).Contains("原料入场记录", StringComparison.OrdinalIgnoreCase) + || (x.BizName ?? string.Empty).Contains("原材料流转卡片", StringComparison.OrdinalIgnoreCase)); + if (bind == null) return false; + + _previewFieldMappingJson = string.IsNullOrWhiteSpace(bind.FieldMappingJson) ? "[]" : bind.FieldMappingJson!; + _previewTemplateCode = string.IsNullOrWhiteSpace(bind.TemplateCode) ? RawMaterialEntryTemplateCode : bind.TemplateCode!; + + var templates = _printTemplateService.GetCached(); + if (templates.Count == 0) + { + try { templates = await _printTemplateService.ListAsync().ConfigureAwait(false); } catch { /* 忽略 */ } + } + var tpl = templates.FirstOrDefault(t => !string.IsNullOrWhiteSpace(bind.TemplateId) && string.Equals(t.Id, bind.TemplateId, StringComparison.OrdinalIgnoreCase)) + ?? templates.FirstOrDefault(t => string.Equals(t.TemplateCode, _previewTemplateCode, StringComparison.OrdinalIgnoreCase)); + if (tpl == null || string.IsNullOrWhiteSpace(tpl.TemplateJson)) return false; + + _previewTemplateJson = tpl.TemplateJson!; + _previewTemplateName = string.IsNullOrWhiteSpace(tpl.TemplateName) ? "原料入场记录" : tpl.TemplateName!; + _previewTemplateLoaded = true; + return true; + } + + private JsonObject BuildLocalPreviewData(MesXslRawMaterialEntry entry) + { + JsonObject printData = new(); + JsonNode? bizRoot = JsonSerializer.SerializeToNode(entry, PreviewSnapshotJsonOpts); + try + { + var mappingNode = JsonNode.Parse(string.IsNullOrWhiteSpace(_previewFieldMappingJson) ? "[]" : _previewFieldMappingJson) as JsonArray; + if (mappingNode != null) + { + foreach (var rule in mappingNode) + { + try + { + if (rule is not JsonObject obj) continue; + var templateField = obj["templateField"]?.GetValue()?.Trim(); + if (string.IsNullOrWhiteSpace(templateField)) continue; + var bizField = obj["bizField"]?.GetValue()?.Trim(); + JsonNode? val = string.IsNullOrWhiteSpace(bizField) + ? JsonValue.Create(string.Empty) + : ResolvePath(bizRoot, bizField!); + // JsonNode 不能同时挂在两个父节点,必须深拷贝后再写入 printData + var normalized = NormalizePreviewNodeValue(val); + SetPath(printData, templateField!, normalized); + } + catch + { + // 单条映射异常不应影响其余字段,继续后续规则 + } + } + } + } + catch + { + // 忽略 mapping JSON 异常,继续尝试按模板字段补空 + } + + try + { + var root = JsonNode.Parse(_previewTemplateJson); + var fields = new HashSet(StringComparer.OrdinalIgnoreCase); + CollectTemplateBindFields(root, fields); + foreach (var key in fields) + { + if (!HasPath(printData, key)) + { + SetPath(printData, key, JsonValue.Create(string.Empty)); + } + } + } + catch + { + // 模板异常时忽略补齐 + } + + return printData; + } + + private static void CollectTemplateBindFields(JsonNode? node, HashSet fields) + { + if (node == null) return; + if (node is JsonObject obj) + { + if (obj["dataBinding"] is JsonObject db && db["params"] is JsonArray paramsArr) + { + foreach (var p in paramsArr) + { + var key = p?["key"]?.GetValue()?.Trim(); + if (!string.IsNullOrWhiteSpace(key)) fields.Add(key!); + } + } + var bindField = obj["bindField"]?.GetValue()?.Trim(); + if (!string.IsNullOrWhiteSpace(bindField)) fields.Add(bindField!); + if (obj["columns"] is JsonArray cols) + { + foreach (var c in cols) + { + var cBind = c?["bindField"]?.GetValue()?.Trim(); + var cField = c?["field"]?.GetValue()?.Trim(); + if (!string.IsNullOrWhiteSpace(cBind)) fields.Add(cBind!); + else if (!string.IsNullOrWhiteSpace(cField)) fields.Add(cField!); + } + } + foreach (var kv in obj) CollectTemplateBindFields(kv.Value, fields); + return; + } + if (node is JsonArray arr) + { + foreach (var item in arr) CollectTemplateBindFields(item, fields); + } + } + + private static bool HasPath(JsonObject root, string path) + { + var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + JsonNode? cur = root; + for (int i = 0; i < parts.Length; i++) + { + if (cur is not JsonObject obj) return false; + if (!obj.TryGetPropertyValue(parts[i], out cur)) return false; + if (i == parts.Length - 1) return true; + } + return false; + } + + private static JsonNode? ResolvePath(JsonNode? root, string path) + { + if (root == null || string.IsNullOrWhiteSpace(path)) return null; + var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + JsonNode? cur = root; + foreach (var p in parts) + { + if (cur == null) return null; + if (cur is JsonArray arr) + { + if (int.TryParse(p, out var idx)) + { + cur = idx >= 0 && idx < arr.Count ? arr[idx] : null; + } + else + { + cur = arr.Count > 0 ? arr[0]?[p] : null; + } + } + else + { + cur = cur[p]; + } + } + return cur; + } + + /// + /// 本地实时预览与后端保持一致:DateTime/DateTimeOffset 统一输出 yyyy-MM-dd HH:mm:ss(不带时区后缀)。 + /// + private static JsonNode NormalizePreviewNodeValue(JsonNode? node) + { + if (node == null) return JsonValue.Create(string.Empty)!; + + if (node is JsonValue v) + { + if (v.TryGetValue(out var dt)) + { + return JsonValue.Create(dt.ToString("yyyy-MM-dd HH:mm:ss"))!; + } + if (v.TryGetValue(out var dto)) + { + return JsonValue.Create(dto.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!; + } + if (v.TryGetValue(out var s) && !string.IsNullOrWhiteSpace(s)) + { + // 仅处理明显的 ISO 时间串,避免误伤普通文本 + if ((s.Contains('T') || s.EndsWith("Z", StringComparison.OrdinalIgnoreCase) || s.Contains('+')) + && DateTimeOffset.TryParse(s, out var parsed)) + { + return JsonValue.Create(parsed.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"))!; + } + } + } + + return node.DeepClone(); + } + + private static void SetPath(JsonObject target, string path, JsonNode value) + { + var parts = path.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return; + JsonObject cur = target; + for (int i = 0; i < parts.Length - 1; i++) + { + if (cur[parts[i]] is not JsonObject child) + { + child = new JsonObject(); + cur[parts[i]] = child; + } + cur = child; + } + cur[parts[^1]] = value; + } + private void LoadLayoutState() { try diff --git a/yy-admin-master/YY.Admin/Views/Print/PrintBizTemplateBindDetailWindow.xaml b/yy-admin-master/YY.Admin/Views/Print/PrintBizTemplateBindDetailWindow.xaml new file mode 100644 index 00000000..d8ba2e88 --- /dev/null +++ b/yy-admin-master/YY.Admin/Views/Print/PrintBizTemplateBindDetailWindow.xaml @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + +