新增打印模板绑定功能,支持业务与打印模板的映射配置。实现打印模板的增删改查操作,优化打印数据的生成逻辑,提升打印模板的灵活性和用户体验。同时,新增打印机查询接口,增强打印服务的可用性和实时性。

This commit is contained in:
geht
2026-05-13 15:49:51 +08:00
parent 210f3614ea
commit c3f8190537
32 changed files with 2323 additions and 229 deletions

View File

@@ -18,5 +18,11 @@
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-boot-base-core</artifactId>
</dependency>
<!-- 复用打印模板模块打印机枚举、业务绑定、PDF 提交队列 -->
<dependency>
<groupId>org.jeecgframework.boot3</groupId>
<artifactId>jeecg-system-biz</artifactId>
<version>${jeecgboot.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -9,9 +9,18 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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 org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.common.util.oConvertUtils;
import org.jeecg.modules.print.entity.PrintBizTemplateBind;
import org.jeecg.modules.print.entity.PrintTemplate;
import org.jeecg.modules.print.service.IPrintBizTemplateBindService;
import org.jeecg.modules.print.service.IPrintTemplateService;
import org.jeecg.modules.print.util.PrintBizDataMappingUtil;
import org.jeecg.modules.xslmes.constant.MesXslCustomerBizStatus;
import org.jeecg.modules.xslmes.entity.MesXslCustomer;
import org.jeecg.modules.xslmes.entity.MesXslRawMaterialCard;
@@ -33,6 +42,8 @@ import org.jeecg.modules.xslmes.service.MesXslStompNotifyService;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import org.apache.commons.lang3.StringUtils;
@@ -59,6 +70,9 @@ public class MesXslDesktopAnonController {
private final IMesXslWarehouseService warehouseService;
private final IMesXslWarehouseAreaService warehouseAreaService;
private final MesXslStompNotifyService stompNotify;
private final IPrintBizTemplateBindService printBizTemplateBindService;
private final IPrintTemplateService printTemplateService;
private final ObjectMapper objectMapper;
// ═══════════════════════════ 车辆管理 ═══════════════════════════
@@ -606,6 +620,34 @@ public class MesXslDesktopAnonController {
return Result.OK((int) count);
}
/** 与 PrintBizTypeCatalog / 业务打印绑定一致 */
private static final String RAW_MATERIAL_CARD_BIZ_CODE = "MES_RAW_MATERIAL_CARD";
@Operation(summary = "原材料卡片-免密准备原生打印数据(桌面端用)")
@GetMapping("/xslmes/mesXslRawMaterialCard/anon/prepareNativePrint")
public Result<Map<String, Object>> rawMaterialCardAnonPrepareNativePrint(@RequestParam(name = "id") String id) {
try {
MesXslRawMaterialCard card = rawMaterialCardService.getById(id);
if (card == null) return Result.error("未找到原材料卡片");
PrintBizTemplateBind bind = printBizTemplateBindService.getByBizCode(RAW_MATERIAL_CARD_BIZ_CODE);
if (bind == null) return Result.error("请先在「业务打印绑定」中配置原材料卡片与打印模板");
PrintTemplate tpl = printTemplateService.getById(bind.getTemplateId());
if (tpl == null) return Result.error("绑定的打印模板不存在");
ArrayNode mapping = PrintBizDataMappingUtil.parseMappingArray(bind.getFieldMappingJson());
JsonNode bizRoot = objectMapper.valueToTree(card);
ObjectNode printData = PrintBizDataMappingUtil.mapBizToPrintData(bizRoot, mapping);
Map<String, Object> out = new HashMap<>(8);
out.put("cardId", card.getId());
out.put("templateCode", bind.getTemplateCode());
out.put("templateJson", tpl.getTemplateJson());
out.put("printData", objectMapper.convertValue(printData, Map.class));
return Result.OK(out);
} catch (Exception e) {
log.error("原材料卡片-免密准备打印数据失败 id={}", id, e);
return Result.error("准备打印数据失败: " + e.getMessage());
}
}
// ═══════════════════════════ 仓库管理(只读,供桌面端下拉选取) ═══════════════════════════
@Operation(summary = "仓库-免密分页列表查询(供桌面端筛选使用)")

View File

@@ -1,27 +1,48 @@
package org.jeecg.modules.xslmes.controller;
import java.util.Arrays;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.xslmes.entity.MesXslRawMaterialCard;
import org.jeecg.modules.xslmes.service.IMesXslRawMaterialCardService;
import org.jeecg.modules.xslmes.service.MesXslStompNotifyService;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.jeecg.common.system.base.controller.JeecgController;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.ModelAndView;
import io.swagger.v3.oas.annotations.tags.Tag;
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 io.swagger.v3.oas.annotations.Operation;
import org.jeecg.common.aspect.annotation.AutoLog;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.Logical;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.print.entity.PrintBizTemplateBind;
import org.jeecg.modules.print.entity.PrintTemplate;
import org.jeecg.modules.print.service.IPrintBizTemplateBindService;
import org.jeecg.modules.print.service.IPrintTemplateService;
import org.jeecg.modules.print.support.PrintServerEnvironmentService;
import org.jeecg.modules.print.support.PrintServerPdfJobService;
import org.jeecg.modules.print.util.PrintBizDataMappingUtil;
import org.jeecg.modules.xslmes.entity.MesXslRawMaterialCard;
import org.jeecg.modules.xslmes.service.IMesXslRawMaterialCardService;
import org.jeecg.modules.xslmes.service.MesXslStompNotifyService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.ModelAndView;
/**
* @Description: 原材料卡片
@@ -34,10 +55,24 @@ import org.apache.shiro.authz.annotation.RequiresPermissions;
@RequestMapping("/xslmes/mesXslRawMaterialCard")
@Slf4j
public class MesXslRawMaterialCardController extends JeecgController<MesXslRawMaterialCard, IMesXslRawMaterialCardService> {
/** 与 PrintBizTypeCatalog / 业务打印绑定一致 */
private static final String RAW_MATERIAL_CARD_BIZ_CODE = "MES_RAW_MATERIAL_CARD";
@Autowired
private IMesXslRawMaterialCardService mesXslRawMaterialCardService;
@Autowired
private MesXslStompNotifyService stompNotify;
@Autowired
private PrintServerEnvironmentService printServerEnvironmentService;
@Autowired
private PrintServerPdfJobService printServerPdfJobService;
@Autowired
private IPrintBizTemplateBindService printBizTemplateBindService;
@Autowired
private IPrintTemplateService printTemplateService;
@Autowired
private ObjectMapper objectMapper;
/**
* 分页列表查询
@@ -122,6 +157,87 @@ public class MesXslRawMaterialCardController extends JeecgController<MesXslRawMa
return Result.OK("批量删除成功!");
}
/**
* 查询可用打印机(与 /print/template/queryPrinters 返回结构一致)
*/
@Operation(summary = "原材料卡片-查询可用打印机")
@GetMapping(value = "/queryPrinters")
/** 有 list 可进页拉打印机;有 edit 可打印拉打印机,避免单权限缺另一项时 403 */
@RequiresPermissions(
value = {"xslmes:mes_xsl_raw_material_card:list", "xslmes:mes_xsl_raw_material_card:edit"},
logical = Logical.OR)
public Result<Map<String, Object>> queryPrinters() {
return Result.OK(printServerEnvironmentService.buildPrinterQueryResult());
}
/**
* 根据业务打印绑定生成模板 JSON + 映射后的 printData供前端生成 PDF 后调用 printPdf
*/
@Operation(summary = "原材料卡片-准备原生打印数据")
@GetMapping(value = "/prepareNativePrint")
/** 与 printPdf 一致:准备模板与打印数据属于「打印」动作,使用 edit 权限 */
@RequiresPermissions("xslmes:mes_xsl_raw_material_card:edit")
public Result<Map<String, Object>> prepareNativePrint(@RequestParam(name = "id") String id) {
try {
MesXslRawMaterialCard card = mesXslRawMaterialCardService.getById(id);
if (card == null) {
return Result.error("未找到原材料卡片");
}
PrintBizTemplateBind bind =
printBizTemplateBindService.getByBizCode(RAW_MATERIAL_CARD_BIZ_CODE);
if (bind == null) {
return Result.error("请先在「业务打印绑定」中配置原材料卡片与打印模板");
}
PrintTemplate tpl = printTemplateService.getById(bind.getTemplateId());
if (tpl == null) {
return Result.error("绑定的打印模板不存在");
}
ArrayNode mapping =
PrintBizDataMappingUtil.parseMappingArray(bind.getFieldMappingJson());
JsonNode bizRoot = objectMapper.valueToTree(card);
ObjectNode printData = PrintBizDataMappingUtil.mapBizToPrintData(bizRoot, mapping);
Map<String, Object> out = new HashMap<>(8);
out.put("cardId", card.getId());
out.put("templateCode", bind.getTemplateCode());
out.put("templateJson", tpl.getTemplateJson());
out.put("paperWidthMm", tpl.getPaperWidthMm());
out.put("paperHeightMm", tpl.getPaperHeightMm());
out.put("paperOrientation", tpl.getPaperOrientation());
out.put("printData", objectMapper.convertValue(printData, Map.class));
return Result.OK(out);
} catch (Exception e) {
log.error("原材料卡片准备打印数据失败 id={}", id, e);
return Result.error("准备打印数据失败: " + e.getMessage());
}
}
/**
* 将前端生成的 PDF Base64 提交到服务器打印机(与 /print/template/directPrintPdf 一致)
*/
@AutoLog(value = "原材料卡片-PDF后端打印")
@Operation(summary = "原材料卡片-PDF后端打印")
@PostMapping(value = "/printPdf")
@RequiresPermissions("xslmes:mes_xsl_raw_material_card:edit")
public Result<String> printPdf(@RequestBody Map<String, Object> body) {
String id = String.valueOf(body.getOrDefault("id", "")).trim();
String printerName = String.valueOf(body.getOrDefault("printerName", "")).trim();
String pdfBase64 = String.valueOf(body.getOrDefault("pdfBase64", "")).trim();
String fileName = String.valueOf(body.getOrDefault("fileName", "")).trim();
if (StringUtils.isBlank(id)) {
return Result.error("id 不能为空");
}
MesXslRawMaterialCard card = mesXslRawMaterialCardService.getById(id);
if (card == null) {
return Result.error("未找到原材料卡片");
}
String prefix =
StringUtils.isNotBlank(card.getBarcode()) ? card.getBarcode() : card.getId();
String fn =
StringUtils.isNotBlank(fileName) ? fileName : ("原材料卡片-" + prefix + ".pdf");
return printServerPdfJobService.submitPdfBase64(
printerName, pdfBase64, fn, "RAW_CARD_" + prefix);
}
/**
* 通过id查询
*/

View File

@@ -0,0 +1,73 @@
package org.jeecg.modules.print.catalog;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.jeecg.modules.print.vo.PrintBizFieldItemVO;
import org.jeecg.modules.print.vo.PrintBizTypeVO;
/**
* 业务类型及可映射字段目录(与实体 JSON 字段名一致camelCase
* 新增业务:在此注册 bizCode 与字段列表。
*/
public final class PrintBizTypeCatalog {
private static final Map<String, PrintBizTypeVO> REGISTRY = new LinkedHashMap<>();
static {
registerRawMaterialCard();
}
private PrintBizTypeCatalog() {}
private static void registerRawMaterialCard() {
PrintBizTypeVO vo = new PrintBizTypeVO();
vo.setBizCode("MES_RAW_MATERIAL_CARD");
vo.setBizName("原材料卡片");
vo.setDescription("mes_xsl_raw_material_card / MesXslRawMaterialCard");
List<PrintBizFieldItemVO> fields = new ArrayList<>();
fields.add(f("id", "主键"));
fields.add(f("barcode", "条码"));
fields.add(f("splitDetailId", "拆码明细行ID"));
fields.add(f("batchNo", "批次号"));
fields.add(f("entryDate", "入场日期"));
fields.add(f("materialId", "物料ID"));
fields.add(f("materialName", "物料名称"));
fields.add(f("materialDesc", "物料描述"));
fields.add(f("supplierId", "供应商ID"));
fields.add(f("supplierName", "供应商名称"));
fields.add(f("manufacturerMaterialName", "厂家物料名称"));
fields.add(f("shelfLife", "保质期"));
fields.add(f("totalWeight", "总重"));
fields.add(f("remainingWeight", "剩余重量"));
fields.add(f("remainingQuantity", "剩余数量"));
fields.add(f("status", "状态(字典)"));
fields.add(f("testResult", "检测结果(字典)"));
fields.add(f("warehouseArea", "库区/库位"));
fields.add(f("unloadOperator", "卸货操作人"));
fields.add(f("priorityPickup", "优先出库"));
fields.add(f("createBy", "创建人"));
fields.add(f("createTime", "创建时间"));
fields.add(f("updateBy", "更新人"));
fields.add(f("updateTime", "更新时间"));
vo.setFields(Collections.unmodifiableList(fields));
REGISTRY.put(vo.getBizCode(), vo);
}
private static PrintBizFieldItemVO f(String key, String label) {
return new PrintBizFieldItemVO(key, label, "");
}
public static List<PrintBizTypeVO> listAll() {
return new ArrayList<>(REGISTRY.values());
}
public static PrintBizTypeVO getByCode(String bizCode) {
if (bizCode == null) {
return null;
}
return REGISTRY.get(bizCode.trim());
}
}

View File

@@ -0,0 +1,216 @@
package org.jeecg.modules.print.controller;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import lombok.Data;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
import org.jeecg.common.aspect.annotation.AutoLog;
import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.print.catalog.PrintBizTypeCatalog;
import org.jeecg.modules.print.entity.PrintBizTemplateBind;
import org.jeecg.modules.print.entity.PrintTemplate;
import org.jeecg.modules.print.service.IPrintBizTemplateBindService;
import org.jeecg.modules.print.service.IPrintTemplateService;
import org.jeecg.modules.print.util.PrintBizDataMappingUtil;
import org.jeecg.modules.print.util.PrintNativeTemplateFieldExtractor;
import org.jeecg.modules.print.vo.PrintBizTypeVO;
import org.jeecg.modules.print.vo.PrintTemplateFieldItemVO;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 业务与打印模板绑定:可视化配置字段映射
*/
@Tag(name = "业务打印绑定")
@RestController
@RequestMapping("/print/bizTemplateBind")
public class PrintBizTemplateBindController extends JeecgController<PrintBizTemplateBind, IPrintBizTemplateBindService> {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Autowired private IPrintTemplateService printTemplateService;
@Operation(summary = "业务打印绑定-分页列表")
@GetMapping("/list")
@RequiresPermissions("print:bizBind:list")
public Result<IPage<PrintBizTemplateBind>> list(
PrintBizTemplateBind query,
@RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo,
@RequestParam(name = "pageSize", defaultValue = "10") Integer pageSize,
HttpServletRequest req) {
QueryWrapper<PrintBizTemplateBind> qw =
QueryGenerator.initQueryWrapper(query, req.getParameterMap());
qw.orderByDesc("create_time");
Page<PrintBizTemplateBind> page = new Page<>(pageNo, pageSize);
return Result.OK(service.page(page, qw));
}
@AutoLog(value = "业务打印绑定-添加")
@Operation(summary = "业务打印绑定-添加")
@PostMapping("/add")
@RequiresPermissions("print:bizBind:add")
public Result<String> add(@RequestBody PrintBizTemplateBind entity) {
String err = validateAndFillTemplate(entity);
if (err != null) {
return Result.error(err);
}
if (service.getByBizCode(entity.getBizCode()) != null) {
return Result.error("该业务编码已存在绑定,请编辑原记录或先删除");
}
normalizeMappingJson(entity);
service.save(entity);
return Result.OK("添加成功");
}
@AutoLog(value = "业务打印绑定-编辑")
@Operation(summary = "业务打印绑定-编辑")
@RequestMapping(value = "/edit", method = {RequestMethod.PUT, RequestMethod.POST})
@RequiresPermissions("print:bizBind:edit")
public Result<String> edit(@RequestBody PrintBizTemplateBind entity) {
PrintBizTemplateBind db = service.getById(entity.getId());
if (db == null) {
return Result.error("记录不存在");
}
String err = validateAndFillTemplate(entity);
if (err != null) {
return Result.error(err);
}
PrintBizTemplateBind other = service.getByBizCode(entity.getBizCode());
if (other != null && !other.getId().equals(entity.getId())) {
return Result.error("业务编码与其他记录冲突");
}
normalizeMappingJson(entity);
service.updateById(entity);
return Result.OK("编辑成功");
}
@AutoLog(value = "业务打印绑定-删除")
@Operation(summary = "业务打印绑定-删除")
@DeleteMapping("/delete")
@RequiresPermissions("print:bizBind:delete")
public Result<String> delete(@RequestParam(name = "id") String id) {
service.removeById(id);
return Result.OK("删除成功");
}
@Operation(summary = "已注册的业务类型及字段目录")
@GetMapping("/bizTypes")
@RequiresPermissions("print:bizBind:list")
public Result<List<PrintBizTypeVO>> bizTypes() {
return Result.OK(PrintBizTypeCatalog.listAll());
}
@Operation(summary = "解析原生模板中的占位字段bindField")
@GetMapping("/parseTemplateFields")
@RequiresPermissions("print:bizBind:list")
public Result<List<PrintTemplateFieldItemVO>> parseTemplateFields(
@RequestParam(name = "templateId") String templateId) {
if (StringUtils.isBlank(templateId)) {
return Result.error("templateId 不能为空");
}
PrintTemplate tpl = printTemplateService.getById(templateId);
if (tpl == null) {
return Result.error("模板不存在");
}
List<PrintTemplateFieldItemVO> fields =
PrintNativeTemplateFieldExtractor.extract(tpl.getTemplateJson());
return Result.OK(fields);
}
@Operation(summary = "按业务编码查询绑定(供打印调用)")
@GetMapping("/queryByBizCode")
@RequiresPermissions("print:bizBind:list")
public Result<PrintBizTemplateBind> queryByBizCode(@RequestParam(name = "bizCode") String bizCode) {
if (StringUtils.isBlank(bizCode)) {
return Result.error("bizCode 不能为空");
}
PrintBizTemplateBind row = service.getByBizCode(bizCode.trim());
if (row == null) {
return Result.error("未配置该业务的打印绑定");
}
return Result.OK(row);
}
@Operation(summary = "预览:业务数据按映射转为模板打印 JSON")
@PostMapping("/previewMappedData")
@RequiresPermissions("print:bizBind:list")
public Result<Map<String, Object>> previewMappedData(@RequestBody PreviewMappedBody body) {
if (body == null || StringUtils.isBlank(body.getBizCode())) {
return Result.error("bizCode 不能为空");
}
PrintBizTemplateBind bind = service.getByBizCode(body.getBizCode().trim());
if (bind == null) {
return Result.error("未配置该业务的打印绑定");
}
try {
ArrayNode mapping = PrintBizDataMappingUtil.parseMappingArray(bind.getFieldMappingJson());
JsonNode bizRoot = PrintBizDataMappingUtil.parseBizJson(body.getBizDataJson());
ObjectNode printData = PrintBizDataMappingUtil.mapBizToPrintData(bizRoot, mapping);
Map<String, Object> res = new HashMap<>(4);
res.put("templateCode", bind.getTemplateCode());
res.put("templateId", bind.getTemplateId());
res.put("printData", OBJECT_MAPPER.convertValue(printData, Map.class));
return Result.OK(res);
} catch (Exception e) {
return Result.error("预览失败:" + e.getMessage());
}
}
/** @return 错误信息null 表示校验通过 */
private String validateAndFillTemplate(PrintBizTemplateBind entity) {
if (entity == null || StringUtils.isBlank(entity.getBizCode())) {
return "业务编码不能为空";
}
if (StringUtils.isBlank(entity.getTemplateId())) {
return "请选择打印模板";
}
PrintTemplate tpl = printTemplateService.getById(entity.getTemplateId());
if (tpl == null) {
return "打印模板不存在";
}
entity.setTemplateCode(tpl.getTemplateCode());
if (PrintBizTypeCatalog.getByCode(entity.getBizCode()) != null
&& StringUtils.isBlank(entity.getBizName())) {
entity.setBizName(PrintBizTypeCatalog.getByCode(entity.getBizCode()).getBizName());
}
return null;
}
/** 确保 mapping JSON 为数组字符串 */
private void normalizeMappingJson(PrintBizTemplateBind entity) {
String raw = entity.getFieldMappingJson();
if (StringUtils.isBlank(raw)) {
entity.setFieldMappingJson("[]");
return;
}
try {
JsonNode n = OBJECT_MAPPER.readTree(raw);
if (!n.isArray()) {
entity.setFieldMappingJson("[]");
}
} catch (Exception e) {
entity.setFieldMappingJson("[]");
}
}
@Data
public static class PreviewMappedBody {
private String bizCode;
/** 业务对象 JSON对象或可解析字符串 */
private Object bizDataJson;
}
}

View File

@@ -6,36 +6,19 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import java.util.ArrayList;
import java.util.Base64;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.stream.Collectors;
import javax.print.Doc;
import javax.print.DocFlavor;
import javax.print.DocPrintJob;
import javax.print.PrintException;
import javax.print.PrintService;
import javax.print.PrintServiceLookup;
import javax.print.SimpleDoc;
import javax.print.attribute.HashPrintRequestAttributeSet;
import javax.print.attribute.standard.JobName;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterAbortException;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.ByteArrayInputStream;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.commons.lang3.StringUtils;
import org.apache.shiro.authz.annotation.RequiresPermissions;
import org.jeecg.common.api.vo.Result;
@@ -44,8 +27,9 @@ import org.jeecg.common.system.base.controller.JeecgController;
import org.jeecg.common.system.query.QueryGenerator;
import org.jeecg.modules.print.entity.PrintTemplate;
import org.jeecg.modules.print.service.IPrintTemplateService;
import org.jeecg.modules.print.support.PrintServerEnvironmentService;
import org.jeecg.modules.print.support.PrintServerPdfJobService;
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;
@@ -59,8 +43,8 @@ import com.alibaba.fastjson.JSON;
@RestController
@RequestMapping("/print/template")
public class PrintTemplateController extends JeecgController<PrintTemplate, IPrintTemplateService> {
@Value("${print.network-printers:}")
private String networkPrinters;
@Autowired private PrintServerEnvironmentService printServerEnvironmentService;
@Autowired private PrintServerPdfJobService printServerPdfJobService;
@Autowired
private INativePrintTemplateImageAnalyzeService nativePrintTemplateImageAnalyzeService;
@@ -225,45 +209,7 @@ public class PrintTemplateController extends JeecgController<PrintTemplate, IPri
@GetMapping(value = "/queryPrinters")
@RequiresPermissions("print:template:list")
public Result<Map<String, Object>> queryPrinters() {
Map<String, Object> res = new HashMap<>(8);
List<String> serverPrinters = new ArrayList<>();
String serverDefaultPrinter = "";
try {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService service : services) {
if (service != null && StringUtils.isNotBlank(service.getName())) {
serverPrinters.add(service.getName().trim());
}
}
}
PrintService defaultService = PrintServiceLookup.lookupDefaultPrintService();
if (defaultService != null && StringUtils.isNotBlank(defaultService.getName())) {
serverDefaultPrinter = defaultService.getName().trim();
}
} catch (Exception e) {
log.warn("查询服务器打印机失败: {}", e.getMessage());
}
List<String> networkPrinterList =
StringUtils.isBlank(networkPrinters)
? new ArrayList<>()
: java.util.Arrays.stream(networkPrinters.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
Map<String, Object> capability = new LinkedHashMap<>(4);
capability.put("localSupported", false);
capability.put("localReason", "浏览器环境无法直接枚举客户端本地打印机,需要本地组件或客户端程序配合。");
capability.put("serverSupported", true);
capability.put("networkSupported", true);
res.put("capability", capability);
res.put("serverPrinters", serverPrinters);
res.put("serverDefaultPrinter", serverDefaultPrinter);
res.put("networkPrinters", networkPrinterList);
return Result.OK(res);
return Result.OK(printServerEnvironmentService.buildPrinterQueryResult());
}
@AutoLog(value = "打印模板-服务端直打")
@@ -286,23 +232,12 @@ public class PrintTemplateController extends JeecgController<PrintTemplate, IPri
return Result.error("模板不存在: " + templateCode);
}
try {
PrintService target = null;
if (StringUtils.isNotBlank(printerName) && !"__system_default__".equals(printerName)) {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService serviceItem : services) {
if (serviceItem != null && printerName.equalsIgnoreCase(String.valueOf(serviceItem.getName()).trim())) {
target = serviceItem;
break;
}
}
}
if (target == null) {
return Result.error("未找到指定打印机: " + printerName);
}
}
if (target == null) {
target = PrintServiceLookup.lookupDefaultPrintService();
PrintService target = printServerPdfJobService.resolvePrintService(printerName);
if (StringUtils.isNotBlank(printerName)
&& !"__system_default__".equals(printerName)
&& target != null
&& !printerName.equalsIgnoreCase(String.valueOf(target.getName()).trim())) {
return Result.error("未找到指定打印机: " + printerName);
}
if (target == null) {
return Result.error("未找到可用打印机,请检查服务器打印机配置");
@@ -362,109 +297,7 @@ public class PrintTemplateController extends JeecgController<PrintTemplate, IPri
if (StringUtils.isBlank(templateCode)) {
return Result.error("templateCode 不能为空");
}
if (StringUtils.isBlank(pdfBase64)) {
return Result.error("pdfBase64 不能为空");
}
String lastResolvedPrinterLabel = null;
try {
PrintService target = resolvePrintService(printerName);
if (target == null) {
return Result.error("未找到可用打印机,请检查服务器打印机配置");
}
final String resolvedPrinterLabel = target.getName();
lastResolvedPrinterLabel = resolvedPrinterLabel;
String base64Body = pdfBase64;
int commaIdx = pdfBase64.indexOf(",");
if (pdfBase64.startsWith("data:") && commaIdx > 0) {
base64Body = pdfBase64.substring(commaIdx + 1);
}
byte[] pdfBytes = Base64.getDecoder().decode(base64Body);
String printJobName = StringUtils.isNotBlank(fileName) ? fileName : ("QH-MES-" + templateCode + ".pdf");
// 优先直送 PDF 字节,避免走 RasterPrinterJob虚拟打印机/无界面会话下易触发 PrinterAbortException
if (tryPrintPdfBytesWithDocFlavor(target, pdfBytes, printJobName)) {
return Result.OK("已提交PDF到服务器打印机: " + resolvedPrinterLabel);
}
try (PDDocument document = PDDocument.load(new ByteArrayInputStream(pdfBytes))) {
PDFRenderer renderer = new PDFRenderer(document);
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(target);
job.setJobName(printJobName);
job.setPrintable(
(graphics, pageFormat, pageIndex) -> {
if (pageIndex >= document.getNumberOfPages()) {
return Printable.NO_SUCH_PAGE;
}
BufferedImage image;
try {
image = renderer.renderImageWithDPI(pageIndex, 150);
} catch (Exception ex) {
throw new PrinterException("PDF页面渲染失败: " + ex.getMessage());
}
Graphics2D g2 = (Graphics2D) graphics;
double imageableX = pageFormat.getImageableX();
double imageableY = pageFormat.getImageableY();
double imageableWidth = pageFormat.getImageableWidth();
double imageableHeight = pageFormat.getImageableHeight();
double scale =
Math.min(imageableWidth / image.getWidth(), imageableHeight / image.getHeight());
int drawWidth = (int) Math.round(image.getWidth() * scale);
int drawHeight = (int) Math.round(image.getHeight() * scale);
int drawX = (int) Math.round(imageableX + (imageableWidth - drawWidth) / 2);
int drawY = (int) Math.round(imageableY + (imageableHeight - drawHeight) / 2);
g2.drawImage(image, drawX, drawY, drawWidth, drawHeight, null);
return Printable.PAGE_EXISTS;
});
HashPrintRequestAttributeSet patts = new HashPrintRequestAttributeSet();
patts.add(new JobName(printJobName, Locale.getDefault()));
job.print(patts);
}
return Result.OK("已提交PDF到服务器打印机: " + resolvedPrinterLabel);
} catch (PrinterAbortException e) {
log.error("PDF后端打印失败(PrinterAbortException)", e);
return Result.error(buildPdfPrinterAbortHint(printerName, lastResolvedPrinterLabel));
} catch (Exception e) {
log.error("PDF后端打印失败", e);
return Result.error("PDF后端打印失败: " + e.getMessage());
}
}
/**
* 若打印机声明支持 application/pdf则通过 DocPrintJob 提交,通常比 AWT 栅格化更稳定。
*/
private boolean tryPrintPdfBytesWithDocFlavor(PrintService printService, byte[] pdfBytes, String jobName) {
DocFlavor flavor = new DocFlavor.INPUT_STREAM("application/pdf");
if (!printService.isDocFlavorSupported(flavor)) {
return false;
}
try {
DocPrintJob docJob = printService.createPrintJob();
ByteArrayInputStream in = new ByteArrayInputStream(pdfBytes);
Doc doc = new SimpleDoc(in, flavor, null);
HashPrintRequestAttributeSet attrs = new HashPrintRequestAttributeSet();
if (StringUtils.isNotBlank(jobName)) {
attrs.add(new JobName(jobName, Locale.getDefault()));
}
docJob.print(doc, attrs);
return true;
} catch (PrintException e) {
log.warn("PDF DocFlavor 直送失败,将回退为位图渲染: {} - {}", printService.getName(), e.getMessage());
return false;
}
}
private static String buildPdfPrinterAbortHint(String requestedPrinterName, String resolvedPrintQueueName) {
StringBuilder sb = new StringBuilder();
sb.append("打印任务被系统取消PrinterAbortException。常见原因");
sb.append("1) 默认或所选为「Microsoft Print to PDF」等虚拟打印机在 Tomcat 等服务进程无交互桌面时无法弹出保存对话框,作业会被中止——请安装实体打印机并在前端指定 printerName");
sb.append("2) 打印机离线、队列暂停、缺纸或驱动报错;");
sb.append("3) 运行服务的 Windows 账户无权访问打印队列。");
if (StringUtils.isNotBlank(resolvedPrintQueueName)) {
sb.append(" 当前实际使用的打印队列: ").append(resolvedPrintQueueName.trim()).append("");
}
if (StringUtils.isNotBlank(requestedPrinterName) && !"__system_default__".equalsIgnoreCase(requestedPrinterName.trim())) {
sb.append(" 请求参数 printerName: ").append(requestedPrinterName.trim()).append("");
}
return sb.toString();
return printServerPdfJobService.submitPdfBase64(printerName, pdfBase64, fileName, templateCode);
}
// ═══════════════════════════ 桌面端免密接口 ═══════════════════════════
@@ -491,26 +324,6 @@ public class PrintTemplateController extends JeecgController<PrintTemplate, IPri
return Result.OK(service.page(page, qw));
}
private PrintService resolvePrintService(String printerName) {
PrintService target = null;
if (StringUtils.isNotBlank(printerName) && !"__system_default__".equals(printerName)) {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService serviceItem : services) {
if (serviceItem != null
&& printerName.equalsIgnoreCase(String.valueOf(serviceItem.getName()).trim())) {
target = serviceItem;
break;
}
}
}
}
if (target == null) {
target = PrintServiceLookup.lookupDefaultPrintService();
}
return target;
}
/**
* 广播打印模板变更事件到 /topic/sync/print-templates桌面端订阅同步刷新本地缓存。
* 消息体格式 = MesXslStompNotifyService.publishPrintTemplateChanged 的输出,

View File

@@ -0,0 +1,47 @@
package org.jeecg.modules.print.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.experimental.Accessors;
import org.jeecg.common.system.base.entity.JeecgEntity;
/**
* 业务与打印模板绑定(字段映射)
*/
@Data
@EqualsAndHashCode(callSuper = true)
@Accessors(chain = true)
@JsonIgnoreProperties(ignoreUnknown = true)
@Schema(description = "业务打印模板绑定")
@TableName("print_biz_template_bind")
public class PrintBizTemplateBind extends JeecgEntity implements Serializable {
@Schema(description = "业务编码")
@TableField("biz_code")
private String bizCode;
@Schema(description = "业务名称")
@TableField("biz_name")
private String bizName;
@Schema(description = "打印模板主键")
@TableField("template_id")
private String templateId;
@Schema(description = "打印模板编码")
@TableField("template_code")
private String templateCode;
@Schema(description = "字段映射 JSON[{templateField,bizField}]")
@TableField("field_mapping_json")
private String fieldMappingJson;
@Schema(description = "备注")
@TableField("remark")
private String remark;
}

View File

@@ -0,0 +1,7 @@
package org.jeecg.modules.print.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.jeecg.modules.print.entity.PrintBizTemplateBind;
/** 业务打印模板绑定 Mapper */
public interface PrintBizTemplateBindMapper extends BaseMapper<PrintBizTemplateBind> {}

View File

@@ -0,0 +1,10 @@
package org.jeecg.modules.print.service;
import com.baomidou.mybatisplus.extension.service.IService;
import org.jeecg.modules.print.entity.PrintBizTemplateBind;
/** 业务打印模板绑定 */
public interface IPrintBizTemplateBindService extends IService<PrintBizTemplateBind> {
PrintBizTemplateBind getByBizCode(String bizCode);
}

View File

@@ -0,0 +1,24 @@
package org.jeecg.modules.print.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.jeecg.modules.print.entity.PrintBizTemplateBind;
import org.jeecg.modules.print.mapper.PrintBizTemplateBindMapper;
import org.jeecg.modules.print.service.IPrintBizTemplateBindService;
import org.springframework.stereotype.Service;
@Service
public class PrintBizTemplateBindServiceImpl extends ServiceImpl<PrintBizTemplateBindMapper, PrintBizTemplateBind>
implements IPrintBizTemplateBindService {
@Override
public PrintBizTemplateBind getByBizCode(String bizCode) {
if (bizCode == null || bizCode.isBlank()) {
return null;
}
LambdaQueryWrapper<PrintBizTemplateBind> q = new LambdaQueryWrapper<>();
q.eq(PrintBizTemplateBind::getBizCode, bizCode.trim());
q.last("LIMIT 1");
return getOne(q);
}
}

View File

@@ -0,0 +1,69 @@
package org.jeecg.modules.print.support;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import javax.print.PrintService;
import javax.print.PrintServiceLookup;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
/**
* 服务端打印机枚举能力(与 {@link org.jeecg.modules.print.controller.PrintTemplateController#queryPrinters} 一致)。
*/
@Slf4j
@Service
public class PrintServerEnvironmentService {
@Value("${print.network-printers:}")
private String networkPrinters;
/** 与打印模板页 /print/template/queryPrinters 返回结构完全一致 */
public Map<String, Object> buildPrinterQueryResult() {
Map<String, Object> res = new HashMap<>(8);
List<String> serverPrinters = new ArrayList<>();
String serverDefaultPrinter = "";
try {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService service : services) {
if (service != null && StringUtils.isNotBlank(service.getName())) {
serverPrinters.add(service.getName().trim());
}
}
}
PrintService defaultService = PrintServiceLookup.lookupDefaultPrintService();
if (defaultService != null && StringUtils.isNotBlank(defaultService.getName())) {
serverDefaultPrinter = defaultService.getName().trim();
}
} catch (Exception e) {
log.warn("查询服务器打印机失败: {}", e.getMessage());
}
List<String> networkPrinterList =
StringUtils.isBlank(networkPrinters)
? new ArrayList<>()
: java.util.Arrays.stream(networkPrinters.split(","))
.map(String::trim)
.filter(StringUtils::isNotBlank)
.distinct()
.collect(Collectors.toList());
Map<String, Object> capability = new LinkedHashMap<>(4);
capability.put("localSupported", false);
capability.put(
"localReason", "浏览器环境无法直接枚举客户端本地打印机,需要本地组件或客户端程序配合。");
capability.put("serverSupported", true);
capability.put("networkSupported", true);
res.put("capability", capability);
res.put("serverPrinters", serverPrinters);
res.put("serverDefaultPrinter", serverDefaultPrinter);
res.put("networkPrinters", networkPrinterList);
return res;
}
}

View File

@@ -0,0 +1,170 @@
package org.jeecg.modules.print.support;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.awt.print.PageFormat;
import java.awt.print.Printable;
import java.awt.print.PrinterAbortException;
import java.awt.print.PrinterException;
import java.awt.print.PrinterJob;
import java.io.ByteArrayInputStream;
import java.util.Base64;
import java.util.Locale;
import javax.print.Doc;
import javax.print.DocFlavor;
import javax.print.DocPrintJob;
import javax.print.PrintException;
import javax.print.PrintService;
import javax.print.PrintServiceLookup;
import javax.print.SimpleDoc;
import javax.print.attribute.HashPrintRequestAttributeSet;
import javax.print.attribute.standard.JobName;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.jeecg.common.api.vo.Result;
import org.springframework.stereotype.Service;
/**
* 服务端将 PDF Base64 提交至打印队列(与打印模板 {@code /print/template/directPrintPdf} 逻辑一致)。
*/
@Slf4j
@Service
public class PrintServerPdfJobService {
/**
* @param templateCodeOrPrefix 用于默认作业名:{@code QH-MES-{code}.pdf}
*/
public Result<String> submitPdfBase64(
String printerName, String pdfBase64, String fileName, String templateCodeOrPrefix) {
if (StringUtils.isBlank(pdfBase64)) {
return Result.error("pdfBase64 不能为空");
}
String lastResolvedPrinterLabel = null;
try {
PrintService target = resolvePrintService(printerName);
if (target == null) {
return Result.error("未找到可用打印机,请检查服务器打印机配置");
}
final String resolvedPrinterLabel = target.getName();
lastResolvedPrinterLabel = resolvedPrinterLabel;
String base64Body = pdfBase64;
int commaIdx = pdfBase64.indexOf(",");
if (pdfBase64.startsWith("data:") && commaIdx > 0) {
base64Body = pdfBase64.substring(commaIdx + 1);
}
byte[] pdfBytes = Base64.getDecoder().decode(base64Body);
String prefix = StringUtils.isNotBlank(templateCodeOrPrefix) ? templateCodeOrPrefix.trim() : "MES";
String printJobName =
StringUtils.isNotBlank(fileName) ? fileName : ("QH-MES-" + prefix + ".pdf");
if (tryPrintPdfBytesWithDocFlavor(target, pdfBytes, printJobName)) {
return Result.OK("已提交PDF到服务器打印机: " + resolvedPrinterLabel);
}
try (PDDocument document = PDDocument.load(new ByteArrayInputStream(pdfBytes))) {
PDFRenderer renderer = new PDFRenderer(document);
PrinterJob job = PrinterJob.getPrinterJob();
job.setPrintService(target);
job.setJobName(printJobName);
job.setPrintable(
(graphics, pageFormat, pageIndex) -> {
if (pageIndex >= document.getNumberOfPages()) {
return Printable.NO_SUCH_PAGE;
}
BufferedImage image;
try {
image = renderer.renderImageWithDPI(pageIndex, 150);
} catch (Exception ex) {
throw new PrinterException("PDF页面渲染失败: " + ex.getMessage());
}
Graphics2D g2 = (Graphics2D) graphics;
double imageableX = pageFormat.getImageableX();
double imageableY = pageFormat.getImageableY();
double imageableWidth = pageFormat.getImageableWidth();
double imageableHeight = pageFormat.getImageableHeight();
double scale =
Math.min(imageableWidth / image.getWidth(), imageableHeight / image.getHeight());
int drawWidth = (int) Math.round(image.getWidth() * scale);
int drawHeight = (int) Math.round(image.getHeight() * scale);
int drawX = (int) Math.round(imageableX + (imageableWidth - drawWidth) / 2);
int drawY = (int) Math.round(imageableY + (imageableHeight - drawHeight) / 2);
g2.drawImage(image, drawX, drawY, drawWidth, drawHeight, null);
return Printable.PAGE_EXISTS;
});
HashPrintRequestAttributeSet patts = new HashPrintRequestAttributeSet();
patts.add(new JobName(printJobName, Locale.getDefault()));
job.print(patts);
}
return Result.OK("已提交PDF到服务器打印机: " + resolvedPrinterLabel);
} catch (PrinterAbortException e) {
log.error("PDF后端打印失败(PrinterAbortException)", e);
return Result.error(buildPdfPrinterAbortHint(printerName, lastResolvedPrinterLabel));
} catch (Exception e) {
log.error("PDF后端打印失败", e);
return Result.error("PDF后端打印失败: " + e.getMessage());
}
}
public PrintService resolvePrintService(String printerName) {
PrintService target = null;
if (StringUtils.isNotBlank(printerName) && !"__system_default__".equals(printerName)) {
PrintService[] services = PrintServiceLookup.lookupPrintServices(null, null);
if (services != null) {
for (PrintService serviceItem : services) {
if (serviceItem != null
&& printerName.equalsIgnoreCase(String.valueOf(serviceItem.getName()).trim())) {
target = serviceItem;
break;
}
}
}
}
if (target == null) {
target = PrintServiceLookup.lookupDefaultPrintService();
}
return target;
}
private boolean tryPrintPdfBytesWithDocFlavor(
PrintService printService, byte[] pdfBytes, String jobName) {
DocFlavor flavor = new DocFlavor.INPUT_STREAM("application/pdf");
if (!printService.isDocFlavorSupported(flavor)) {
return false;
}
try {
DocPrintJob docJob = printService.createPrintJob();
ByteArrayInputStream in = new ByteArrayInputStream(pdfBytes);
Doc doc = new SimpleDoc(in, flavor, null);
HashPrintRequestAttributeSet attrs = new HashPrintRequestAttributeSet();
if (StringUtils.isNotBlank(jobName)) {
attrs.add(new JobName(jobName, Locale.getDefault()));
}
docJob.print(doc, attrs);
return true;
} catch (PrintException e) {
log.warn(
"PDF DocFlavor 直送失败,将回退为位图渲染: {} - {}",
printService.getName(),
e.getMessage());
return false;
}
}
private static String buildPdfPrinterAbortHint(
String requestedPrinterName, String resolvedPrintQueueName) {
StringBuilder sb = new StringBuilder();
sb.append("打印任务被系统取消PrinterAbortException。常见原因");
sb.append(
"1) 默认或所选为「Microsoft Print to PDF」等虚拟打印机在 Tomcat 等服务进程无交互桌面时无法弹出保存对话框,作业会被中止——请安装实体打印机并在前端指定 printerName");
sb.append("2) 打印机离线、队列暂停、缺纸或驱动报错;");
sb.append("3) 运行服务的 Windows 账户无权访问打印队列。");
if (StringUtils.isNotBlank(resolvedPrintQueueName)) {
sb.append(" 当前实际使用的打印队列: ").append(resolvedPrintQueueName.trim()).append("");
}
if (StringUtils.isNotBlank(requestedPrinterName)
&& !"__system_default__".equalsIgnoreCase(requestedPrinterName.trim())) {
sb.append(" 请求参数 printerName: ").append(requestedPrinterName.trim()).append("");
}
return sb.toString();
}
}

View File

@@ -0,0 +1,129 @@
package org.jeecg.modules.print.util;
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 org.apache.commons.lang3.StringUtils;
/** 按映射规则把业务 JSON 转为模板打印数据(键为模板 bindField */
public final class PrintBizDataMappingUtil {
private static final ObjectMapper MAPPER = new ObjectMapper();
private PrintBizDataMappingUtil() {}
public static ObjectNode mapBizToPrintData(JsonNode bizRoot, ArrayNode mappingRules) {
ObjectNode printData = MAPPER.createObjectNode();
if (bizRoot == null || mappingRules == null) {
return printData;
}
for (JsonNode rule : mappingRules) {
if (rule == null || !rule.isObject()) {
continue;
}
String templateField = text(rule, "templateField");
String bizField = text(rule, "bizField");
if (StringUtils.isAnyBlank(templateField, bizField)) {
continue;
}
JsonNode val = resolvePath(bizRoot, bizField);
setPath(printData, templateField, val);
}
return printData;
}
private static JsonNode resolvePath(JsonNode root, String path) {
if (root == null || StringUtils.isBlank(path)) {
return null;
}
String[] parts = path.split("\\.");
JsonNode cur = root;
for (String p : parts) {
if (cur == null || p.isEmpty()) {
return null;
}
cur = cur.get(p);
}
return cur;
}
private static void setPath(ObjectNode target, String path, JsonNode value) {
if (target == null || StringUtils.isBlank(path)) {
return;
}
String[] parts = path.split("\\.");
if (parts.length == 1) {
putLeaf(target, parts[0], value);
return;
}
ObjectNode cur = target;
for (int i = 0; i < parts.length - 1; i++) {
String p = parts[i];
JsonNode next = cur.get(p);
if (next == null || !next.isObject()) {
ObjectNode created = MAPPER.createObjectNode();
cur.set(p, created);
cur = created;
} else {
cur = (ObjectNode) next;
}
}
String leaf = parts[parts.length - 1];
putLeaf(cur, leaf, value);
}
private static void putLeaf(ObjectNode node, String key, JsonNode value) {
if (value == null || value.isNull()) {
node.putNull(key);
} else if (value.isObject() || value.isArray()) {
node.set(key, value);
} else if (value.isTextual()) {
node.put(key, value.asText());
} else if (value.isBoolean()) {
node.put(key, value.booleanValue());
} else if (value.isNumber()) {
node.put(key, value.doubleValue());
} else {
node.set(key, value);
}
}
private static String text(JsonNode n, String key) {
if (n == null || !n.isObject()) {
return "";
}
JsonNode v = n.get(key);
return v == null || v.isNull() ? "" : v.asText("");
}
/** 将任意 JsonNode 转为可序列化的 JSON 树(复制) */
public static JsonNode parseBizJson(Object bizDataJson) throws Exception {
if (bizDataJson == null) {
return MAPPER.createObjectNode();
}
if (bizDataJson instanceof String s) {
if (StringUtils.isBlank(s)) {
return MAPPER.createObjectNode();
}
return MAPPER.readTree(s);
}
return MAPPER.valueToTree(bizDataJson);
}
/** 规范化映射列表:解析字符串或数组 */
public static ArrayNode parseMappingArray(String fieldMappingJson) throws Exception {
if (StringUtils.isBlank(fieldMappingJson)) {
return MAPPER.createArrayNode();
}
JsonNode n = MAPPER.readTree(fieldMappingJson);
if (n.isArray()) {
return (ArrayNode) n;
}
return MAPPER.createArrayNode();
}
public static String mappingArrayToJson(ArrayNode arr) throws Exception {
return MAPPER.writeValueAsString(arr);
}
}

View File

@@ -0,0 +1,141 @@
package org.jeecg.modules.print.util;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import org.apache.commons.lang3.StringUtils;
import org.jeecg.modules.print.vo.PrintTemplateFieldItemVO;
/**
* 从原生打印模板 JSON 中收集所有 bindField / 表格列 field供业务字段映射使用。
*
* <p>原生设计器在 {@code dataBinding.params} 中维护「参数」列表(如 Parameter1Parameter10画布元素通过
* bindField 引用这些 key仅扫描 elements 会漏掉未拖放到画布上的参数,故必须先合并 dataBinding。
*/
public final class PrintNativeTemplateFieldExtractor {
private static final ObjectMapper MAPPER = new ObjectMapper();
private PrintNativeTemplateFieldExtractor() {}
public static List<PrintTemplateFieldItemVO> extract(String templateJson) {
List<PrintTemplateFieldItemVO> list = new ArrayList<>();
if (StringUtils.isBlank(templateJson)) {
return list;
}
Set<String> seen = new LinkedHashSet<>();
try {
JsonNode root = MAPPER.readTree(templateJson);
// 优先收录设计器「参数/明细字段」目录,保证显示名与组件库一致,且不被后续元素扫描覆盖
collectDataBinding(root, seen, list);
JsonNode elements = root.get("elements");
if (elements != null && elements.isArray()) {
for (JsonNode el : elements) {
collectFromElement(el, seen, list);
}
}
} catch (Exception ignored) {
return list;
}
return list;
}
/** 解析 schema.dataBindingparams参数键、detailTables明细字段 */
private static void collectDataBinding(JsonNode root, Set<String> seen, List<PrintTemplateFieldItemVO> list) {
JsonNode db = root.get("dataBinding");
if (db == null || !db.isObject()) {
return;
}
JsonNode params = db.get("params");
if (params != null && params.isArray()) {
for (JsonNode p : params) {
if (p == null || !p.isObject()) {
continue;
}
String key = text(p, "key").trim();
if (StringUtils.isBlank(key) || !seen.add(key)) {
continue;
}
list.add(new PrintTemplateFieldItemVO(key, "param", text(p, "label")));
}
}
JsonNode detailTables = db.get("detailTables");
if (detailTables != null && detailTables.isArray()) {
for (JsonNode t : detailTables) {
if (t == null || !t.isObject()) {
continue;
}
String tableKey = text(t, "tableKey").trim();
JsonNode fields = t.get("fields");
if (fields == null || !fields.isArray()) {
continue;
}
for (JsonNode f : fields) {
if (f == null || !f.isObject()) {
continue;
}
String fk = text(f, "key").trim();
if (StringUtils.isBlank(fk)) {
continue;
}
// 与画布列 bindField 一致时多为短 key多表明细同字段再加 tableKey 前缀消歧
String bindKey = fk;
if (seen.contains(bindKey) && StringUtils.isNotBlank(tableKey)) {
bindKey = tableKey + "." + fk;
}
if (seen.contains(bindKey) || !seen.add(bindKey)) {
continue;
}
list.add(new PrintTemplateFieldItemVO(bindKey, "detailField", text(f, "label")));
}
}
}
}
private static void collectFromElement(JsonNode el, Set<String> seen, List<PrintTemplateFieldItemVO> list) {
if (el == null || !el.isObject()) {
return;
}
String bindField = text(el, "bindField");
if (StringUtils.isNotBlank(bindField) && seen.add(bindField.trim())) {
list.add(
new PrintTemplateFieldItemVO(
bindField.trim(),
text(el, "type"),
firstNonBlank(text(el, "title"), text(el, "text"))));
}
JsonNode cols = el.get("columns");
if (cols != null && cols.isArray()) {
for (JsonNode c : cols) {
String field = firstNonBlank(text(c, "bindField"), text(c, "field"));
if (StringUtils.isNotBlank(field) && seen.add(field.trim())) {
list.add(new PrintTemplateFieldItemVO(field.trim(), "column", text(c, "title")));
}
}
}
JsonNode nested = el.get("elements");
if (nested != null && nested.isArray()) {
for (JsonNode child : nested) {
collectFromElement(child, seen, list);
}
}
}
private static String text(JsonNode n, String key) {
if (n == null || !n.isObject()) {
return "";
}
JsonNode v = n.get(key);
return v == null || v.isNull() ? "" : v.asText("");
}
private static String firstNonBlank(String a, String b) {
if (StringUtils.isNotBlank(a)) {
return a;
}
return StringUtils.isNotBlank(b) ? b : "";
}
}

View File

@@ -0,0 +1,22 @@
package org.jeecg.modules.print.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "业务实体可选字段")
public class PrintBizFieldItemVO implements Serializable {
@Schema(description = "字段路径(支持 a.b与 JSON 一致)")
private String fieldKey;
@Schema(description = "展示名称")
private String label;
@Schema(description = "说明")
private String description;
}

View File

@@ -0,0 +1,22 @@
package org.jeecg.modules.print.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import java.util.List;
import lombok.Data;
@Data
@Schema(description = "可配置的业务类型")
public class PrintBizTypeVO implements Serializable {
@Schema(description = "业务编码")
private String bizCode;
@Schema(description = "业务名称")
private String bizName;
@Schema(description = "说明")
private String description;
@Schema(description = "业务侧可用字段目录")
private List<PrintBizFieldItemVO> fields;
}

View File

@@ -0,0 +1,22 @@
package org.jeecg.modules.print.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import java.io.Serializable;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "模板中的数据占位字段")
public class PrintTemplateFieldItemVO implements Serializable {
@Schema(description = "模板 bindField 路径")
private String bindField;
@Schema(description = "元素类型")
private String elementType;
@Schema(description = "元素标题/提示")
private String titleHint;
}

View File

@@ -0,0 +1,43 @@
-- 业务与打印模板绑定字段映射可视化配置
CREATE TABLE IF NOT EXISTS `print_biz_template_bind` (
`id` varchar(36) NOT NULL COMMENT '主键',
`biz_code` varchar(64) NOT NULL COMMENT '业务编码 MES_RAW_MATERIAL_CARD',
`biz_name` varchar(128) DEFAULT NULL COMMENT '业务名称冗余展示',
`template_id` varchar(36) NOT NULL COMMENT '打印模板主键',
`template_code` varchar(64) NOT NULL COMMENT '打印模板编码冗余便于调用方查询',
`field_mapping_json` longtext COMMENT '字段映射 JSON[{templateField,bizField}]templateField 对应模板 bindField',
`remark` varchar(500) DEFAULT NULL COMMENT '备注',
`create_by` varchar(50) DEFAULT NULL COMMENT '创建人',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_by` varchar(50) DEFAULT NULL COMMENT '更新人',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_print_biz_template_bind_biz` (`biz_code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务打印模板绑定';
-- 菜单打印管理下业务打印绑定
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`)
SELECT '1900000000000000120', '1900000000000000100', '业务打印绑定', '/print/bizTemplateBind', 'print/bizTemplateBind/index', 1, 'PrintBizTemplateBind', NULL, 1, NULL, '0', 3.00, 0, 'ant-design:link-outlined', 1, 1, 0, 0, '业务与打印模板字段映射配置', 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000120');
-- 按钮权限
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`)
SELECT '1900000000000000121', '1900000000000000120', '业务打印绑定-查询', NULL, NULL, 0, NULL, NULL, 2, 'print:bizBind:list', '1', 1.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000121');
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`)
SELECT '1900000000000000122', '1900000000000000120', '业务打印绑定-添加', NULL, NULL, 0, NULL, NULL, 2, 'print:bizBind:add', '1', 2.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000122');
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`)
SELECT '1900000000000000123', '1900000000000000120', '业务打印绑定-编辑', NULL, NULL, 0, NULL, NULL, 2, 'print:bizBind:edit', '1', 3.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000123');
INSERT INTO `sys_permission` (`id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, `del_flag`, `rule_flag`, `status`, `internal_or_external`)
SELECT '1900000000000000124', '1900000000000000120', '业务打印绑定-删除', NULL, NULL, 0, NULL, NULL, 2, 'print:bizBind:delete', '1', 4.00, 0, NULL, 1, 0, 0, 0, NULL, 'admin', NOW(), 'admin', NOW(), 0, 0, '1', 0
FROM DUAL
WHERE NOT EXISTS (SELECT 1 FROM `sys_permission` WHERE `id` = '1900000000000000124');

View File

@@ -0,0 +1,29 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/print/bizTemplateBind/list',
add = '/print/bizTemplateBind/add',
edit = '/print/bizTemplateBind/edit',
deleteOne = '/print/bizTemplateBind/delete',
bizTypes = '/print/bizTemplateBind/bizTypes',
parseTemplateFields = '/print/bizTemplateBind/parseTemplateFields',
previewMappedData = '/print/bizTemplateBind/previewMappedData',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
// 与系统其它模块一致body 走 params 键
export const add = (params) => defHttp.post({ url: Api.add, params });
export const edit = (params) => defHttp.put({ url: Api.edit, params });
export const deleteOne = (params, handleSuccess?) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess?.());
export const bizTypes = () => defHttp.get({ url: Api.bizTypes });
export const parseTemplateFields = (templateId: string) =>
defHttp.get({
url: Api.parseTemplateFields,
params: { templateId, _t: Date.now() },
});
/** 预览映射后的打印数据 */
export const previewMappedData = (data: { bizCode: string; bizDataJson: Record<string, unknown> }) =>
defHttp.post({ url: Api.previewMappedData, data });

View File

@@ -0,0 +1,8 @@
import type { BasicColumn } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务编码', dataIndex: 'bizCode', width: 200 },
{ title: '业务名称', dataIndex: 'bizName', width: 140 },
{ title: '模板编码', dataIndex: 'templateCode', width: 180 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
];

View File

@@ -0,0 +1,414 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button type="primary" @click="openCreate" v-auth="'print:bizBind:add'">新增绑定</a-button>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{
label: '编辑',
onClick: () => openEdit(record),
auth: 'print:bizBind:edit',
},
{
label: '删除',
color: 'error',
popConfirm: {
title: '确认删除该绑定?',
confirm: () => handleDelete(record),
},
auth: 'print:bizBind:delete',
},
]"
/>
</template>
</BasicTable>
<BasicModal
@register="registerModal"
:title="modalTitle"
width="920px"
@ok="submitModal"
:confirm-loading="modalSubmitLoading"
destroy-on-close
>
<a-spin :spinning="tplLoading || parseLoading">
<a-space direction="vertical" style="width: 100%" size="middle">
<a-alert
type="info"
show-icon
message="配置步骤"
description="1选择业务类型2选择已发布的打印模板3为模板每个占位字段bindField指定对应的业务 JSON 字段;可点击「同名匹配」快速对齐。"
/>
<a-form layout="vertical">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="业务" required>
<a-select
v-model:value="form.bizCode"
:options="bizSelectOptions"
placeholder="选择业务"
show-search
option-filter-prop="label"
:disabled="isEditMode"
@change="onBizCodeChange"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="打印模板" required>
<a-select
v-model:value="form.templateId"
:options="tplSelectOptions"
placeholder="选择模板"
show-search
option-filter-prop="label"
@change="onTemplateChange"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item label="备注">
<a-input v-model:value="form.remark" placeholder="可选" />
</a-form-item>
</a-form>
<a-space wrap>
<a-button type="primary" ghost @click="reloadTemplateFields" :loading="parseLoading">
解析模板占位字段
</a-button>
<a-button @click="autoMatchFields" :disabled="!bizFields.length || !tplFields.length">
同名自动匹配
</a-button>
</a-space>
<div v-if="tplFields.length">
<div style="margin-bottom: 8px; font-weight: 500">字段映射</div>
<a-table
size="small"
row-key="templateField"
:pagination="false"
:columns="mapTableColumns"
:data-source="mappingRows"
bordered
>
<template #bodyCell="{ column, record }">
<template v-if="column.key === 'bizField'">
<a-select
v-model:value="record.bizField"
:options="bizFieldOptions"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
placeholder="选择业务字段"
/>
</template>
</template>
</a-table>
</div>
<a-empty v-else-if="form.templateId && !parseLoading" description="请点击「解析模板占位字段」或切换模板" />
<a-divider />
<div style="font-weight: 500">映射预览可选</div>
<a-textarea
v-model:value="previewBizJson"
placeholder='粘贴业务 JSON例如{"barcode":"TEST001","materialName":"胶料A"}'
:rows="4"
/>
<a-button type="dashed" @click="runPreview" :loading="previewLoading">生成打印数据预览</a-button>
<pre v-if="previewResult" class="preview-pre">{{ previewResult }}</pre>
</a-space>
</a-spin>
</BasicModal>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, unref } from 'vue';
import { BasicTable, TableAction, useTable } from '/@/components/Table';
import { BasicModal, useModal } from '/@/components/Modal';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns } from './bizTemplateBind.data';
import * as Api from './bizTemplateBind.api';
import { list as tplList } from '../template/printTemplate.api';
const { createMessage } = useMessage();
interface BizTypeItem {
bizCode: string;
bizName: string;
fields: { fieldKey: string; label: string; description?: string }[];
}
interface TplFieldItem {
bindField: string;
elementType?: string;
titleHint?: string;
}
interface MappingRow {
templateField: string;
bizField?: string;
elementType?: string;
titleHint?: string;
}
const bizTypesRef = ref<BizTypeItem[]>([]);
const tplListRef = ref<{ id: string; templateCode: string; templateName: string }[]>([]);
const tplLoading = ref(false);
const parseLoading = ref(false);
const modalSubmitLoading = ref(false);
const previewLoading = ref(false);
const previewBizJson = ref('');
const previewResult = ref('');
const form = ref({
id: '' as string | undefined,
bizCode: undefined as string | undefined,
bizName: '' as string | undefined,
templateId: undefined as string | undefined,
remark: '' as string | undefined,
});
const tplFields = ref<TplFieldItem[]>([]);
const bizFields = ref<BizTypeItem['fields']>([]);
const mappingRows = ref<MappingRow[]>([]);
const isEditMode = ref(false);
const modalTitle = computed(() => (unref(isEditMode) ? '编辑业务打印绑定' : '新增业务打印绑定'));
const bizSelectOptions = computed(() =>
unref(bizTypesRef).map((b) => ({
label: `${b.bizName}${b.bizCode}`,
value: b.bizCode,
})),
);
const tplSelectOptions = computed(() =>
unref(tplListRef).map((t) => ({
label: `${t.templateName}${t.templateCode}`,
value: t.id,
})),
);
const bizFieldOptions = computed(() =>
unref(bizFields).map((f) => ({
label: f.label ? `${f.label}${f.fieldKey}` : f.fieldKey,
value: f.fieldKey,
})),
);
const mapTableColumns = [
{ title: '模板占位bindField', dataIndex: 'templateField', width: 220 },
{ title: '类型', dataIndex: 'elementType', width: 100 },
{ title: '标题/提示', dataIndex: 'titleHint', ellipsis: true },
{ title: '业务字段', key: 'bizField', width: 260 },
];
const [registerTable, { reload }] = useTable({
title: '业务打印绑定',
api: Api.list,
columns,
useSearchForm: false,
showTableSetting: true,
bordered: true,
showIndexColumn: true,
// 必须与模板插槽 #action 对应否则操作列不会渲染useTable 无 useListPage 的默认 slots
actionColumn: {
width: 160,
title: '操作',
fixed: 'right',
dataIndex: 'action',
slots: { customRender: 'action' },
},
});
const [registerModal, { openModal, closeModal }] = useModal();
async function loadBizTypes() {
const res = await Api.bizTypes();
bizTypesRef.value = res || [];
}
async function loadAllTemplates() {
tplLoading.value = true;
try {
const res = await tplList({ pageNo: 1, pageSize: 500 });
tplListRef.value = res?.records ?? [];
} finally {
tplLoading.value = false;
}
}
function onBizCodeChange(code: string) {
const hit = unref(bizTypesRef).find((b) => b.bizCode === code);
bizFields.value = hit?.fields ?? [];
form.value.bizName = hit?.bizName;
}
async function onTemplateChange() {
tplFields.value = [];
mappingRows.value = [];
await reloadTemplateFields();
}
async function reloadTemplateFields() {
const tid = form.value.templateId;
if (!tid) {
tplFields.value = [];
mappingRows.value = [];
return;
}
parseLoading.value = true;
try {
const list = (await Api.parseTemplateFields(tid)) as TplFieldItem[];
tplFields.value = list || [];
rebuildMappingRows();
} finally {
parseLoading.value = false;
}
}
function rebuildMappingRows() {
const saved = unref(savedMappingRef);
mappingRows.value = unref(tplFields).map((t) => {
const templateField = t.bindField;
const hit = saved.find((x) => x.templateField === templateField);
return {
templateField,
bizField: hit?.bizField,
elementType: t.elementType,
titleHint: t.titleHint,
};
});
}
const savedMappingRef = ref<{ templateField: string; bizField?: string }[]>([]);
function autoMatchFields() {
const set = new Map(unref(bizFields).map((f) => [f.fieldKey, f.fieldKey]));
for (const row of unref(mappingRows)) {
if (set.has(row.templateField)) {
row.bizField = row.templateField;
}
}
mappingRows.value = [...unref(mappingRows)];
}
function buildFieldMappingJson() {
const arr = unref(mappingRows)
.filter((r) => r.bizField)
.map((r) => ({ templateField: r.templateField, bizField: r.bizField }));
return JSON.stringify(arr);
}
async function openCreate() {
isEditMode.value = false;
savedMappingRef.value = [];
form.value = { id: undefined, bizCode: undefined, bizName: undefined, templateId: undefined, remark: undefined };
tplFields.value = [];
bizFields.value = [];
mappingRows.value = [];
previewBizJson.value = '';
previewResult.value = '';
await loadBizTypes();
await loadAllTemplates();
openModal(true);
}
async function openEdit(record: Recordable) {
isEditMode.value = true;
await loadBizTypes();
await loadAllTemplates();
form.value = {
id: record.id,
bizCode: record.bizCode,
bizName: record.bizName,
templateId: record.templateId,
remark: record.remark,
};
onBizCodeChange(record.bizCode);
try {
savedMappingRef.value = JSON.parse(record.fieldMappingJson || '[]');
} catch {
savedMappingRef.value = [];
}
previewBizJson.value = '';
previewResult.value = '';
openModal(true);
await reloadTemplateFields();
}
async function submitModal() {
if (!form.value.bizCode) {
createMessage.warning('请选择业务');
return Promise.reject();
}
if (!form.value.templateId) {
createMessage.warning('请选择打印模板');
return Promise.reject();
}
modalSubmitLoading.value = true;
try {
const payload = {
id: form.value.id,
bizCode: form.value.bizCode,
bizName: form.value.bizName,
templateId: form.value.templateId,
remark: form.value.remark,
fieldMappingJson: buildFieldMappingJson(),
};
if (unref(isEditMode)) {
await Api.edit(payload);
} else {
await Api.add(payload);
}
closeModal();
reload();
} finally {
modalSubmitLoading.value = false;
}
}
async function handleDelete(record: Recordable) {
await Api.deleteOne({ id: record.id }, () => reload());
}
async function runPreview() {
if (!form.value.bizCode) {
createMessage.warning('请先选择业务;预览读取的是服务器已保存的绑定配置');
return;
}
let obj: Record<string, unknown> = {};
try {
obj = previewBizJson.value ? (JSON.parse(previewBizJson.value) as Record<string, unknown>) : {};
} catch {
previewResult.value = '业务 JSON 格式不正确';
return;
}
previewLoading.value = true;
try {
const res = await Api.previewMappedData({ bizCode: form.value.bizCode, bizDataJson: obj });
previewResult.value = JSON.stringify(res, null, 2);
} catch (e: unknown) {
previewResult.value = e instanceof Error ? e.message : String(e);
} finally {
previewLoading.value = false;
}
}
</script>
<style scoped>
.preview-pre {
background: #f5f5f5;
padding: 12px;
border-radius: 4px;
max-height: 240px;
overflow: auto;
font-size: 12px;
}
</style>

View File

@@ -92,6 +92,20 @@ function enhancePrintDotErrorMessage(raw: string): string {
if (/SumatraPDF\.exe not found/i.test(m) || /SUMATRAPDF_PATH/i.test(m)) {
return `${m}。本地处理PrintDot 依赖 SumatraPDF 静默打印 PDF。请安装 Sumatra PDF 后任选其一:将 SumatraPDF.exe 放在 PrintDot 客户端 exe 同目录;或将 Sumatra 安装目录加入系统 PATH或设置用户/系统环境变量 SUMATRAPDF_PATH 指向 SumatraPDF.exe 的完整路径,然后重启 PrintDot。`;
}
/** 桥接端在等待 Windows 打印队列接受作业(默认约 2 分钟)未果 */
if (/not queued/i.test(m) || /Printed\s+0\s*\/\s*\d+\s+copies/i.test(m)) {
return `${m}
【说明】PrintDot 已通过 SumatraPDF 发起静默打印,但在约定时间内未检测到作业进入系统打印队列。
【建议逐项排查】
1. 打印机是否开机、联网(网络打印机)、线缆/USB 是否正常。
2. Windows「设备和打印机」中该打印机是否就绪、无暂停打印队列里是否有卡住的任务可先清空队列
3. 下拉选择的打印机名称是否与系统完全一致(可在本页「刷新打印机」后重选)。
4. 重启「Print Spooler」打印后台服务或重启 PrintDot 客户端后再试。
5. 模板版面过大时生成的 PDF 体积大,可能导致 Sumatra 处理变慢——可先简化模板或缩小画布后再试。
6. 若频繁超时,需在 PrintDot 桌面端放宽「队列确认」超时(该 2 分钟由客户端决定,浏览器无法修改)。`;
}
return m;
}

View File

@@ -56,6 +56,11 @@ export type BuildPdfFromHtmlOptions = {
* false整张版面压成一页 PDF长图一页一般仅特殊场景使用
*/
paginate?: boolean;
/**
* 是否严格使用入参纸张尺寸(默认 false 保持历史行为)。
* 原生模板桥接打印建议开启,避免内容测量误差把小标签纸扩成 A4。
*/
exactPaperSize?: boolean;
};
/**
@@ -70,6 +75,7 @@ export async function buildPdfBase64FromHtmlFragment(
options: BuildPdfFromHtmlOptions = {},
): Promise<string> {
const paginate = options.paginate !== false;
const exactPaperSize = options.exactPaperSize === true;
const [{ jsPDF }, html2canvasModule] = await Promise.all([import('jspdf'), import('html2canvas')]);
const html2canvas = html2canvasModule.default;
const container = document.createElement('div');
@@ -152,10 +158,11 @@ export async function buildPdfBase64FromHtmlFragment(
const pad = 1;
if (paginate) {
const sheetW = Math.max(widthMm, pxToMm(sw) + pad);
const sheetW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, pxToMm(sw) + pad);
const sheetH = Math.max(1, heightMm);
const sliceH = Math.max(1, Math.round(mmToPx(sheetH) * scale));
const pdf = new jsPDF({ unit: 'mm', format: [sheetW, sheetH] });
const orientation = sheetW > sheetH ? 'landscape' : 'portrait';
const pdf = new jsPDF({ unit: 'mm', orientation, format: [sheetW, sheetH] });
let y = 0;
let first = true;
/** 余量不足一页高的 2% 时视为测量噪声,避免多出一页空白 */
@@ -193,7 +200,7 @@ export async function buildPdfBase64FromHtmlFragment(
// 单页长图模式paginate: false
const contentWidthMm = pxToMm(sw);
const contentHeightMm = pxToMm(sh);
const minW = Math.max(widthMm, contentWidthMm) + pad;
const minW = exactPaperSize ? Math.max(1, widthMm) : Math.max(widthMm, contentWidthMm) + pad;
const minH = Math.max(heightMm, contentHeightMm) + pad;
const canvasRatio = cw / ch;
let pdfH = Math.max(minH, minW / canvasRatio);
@@ -202,7 +209,8 @@ export async function buildPdfBase64FromHtmlFragment(
pdfW = minW;
pdfH = pdfW / canvasRatio;
}
const pdf = new jsPDF({ unit: 'mm', format: [pdfW, pdfH] });
const orientation = pdfW > pdfH ? 'landscape' : 'portrait';
const pdf = new jsPDF({ unit: 'mm', orientation, format: [pdfW, pdfH] });
const imgData = canvas.toDataURL('image/jpeg', 0.92);
pdf.addImage(imgData, 'JPEG', 0, 0, pdfW, pdfH);
return arrayBufferToBase64(pdf.output('arraybuffer'));

View File

@@ -20,13 +20,14 @@ export async function printNativeSchemaViaPrintDot(params: {
const inner = extractBodyInnerHtmlFromFullDocument(fullHtml);
const pdfBase64 = await buildPdfBase64FromHtmlFragment(inner, params.schema.page.width, params.schema.page.height, {
paginate: true,
exactPaperSize: true,
});
const printers = await fetchPrintDotPrinters();
const fromStore =
params.printerSelection ?? localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ?? '__system_default__';
const resolved = resolvePrintDotPrinterName(fromStore, printers);
if (!resolved) {
throw new Error('未解析到可用打印机:请在模板列表选择打印机,或启动 PrintDot 后刷新打印机列表');
throw new Error('未解析到可用打印机:请在本页或打印模板页选择打印机,并确保本机 PrintDot 已启动后刷新打印机列表');
}
const result = await printDotSendPdf({
printer: resolved,

View File

@@ -12,6 +12,10 @@ enum Api {
importExcel = '/xslmes/mesXslRawMaterialCard/importExcel',
exportXls = '/xslmes/mesXslRawMaterialCard/exportXls',
updatePriority = '/xslmes/mesXslRawMaterialCard/updatePriority',
/** 与打印模板页 queryPrinters 返回结构一致 */
queryPrinters = '/xslmes/mesXslRawMaterialCard/queryPrinters',
prepareNativePrint = '/xslmes/mesXslRawMaterialCard/prepareNativePrint',
printPdf = '/xslmes/mesXslRawMaterialCard/printPdf',
}
export const getExportUrl = Api.exportXls;
@@ -47,3 +51,15 @@ export const saveOrUpdate = (params, isUpdate) => {
export const updatePriority = (id: string, priorityPickup: string) =>
defHttp.put({ url: Api.updatePriority, params: { id, priorityPickup } }, { joinParamsToUrl: true });
export const queryPrinters = () => defHttp.get({ url: Api.queryPrinters });
export const prepareNativePrint = (id: string) =>
defHttp.get({
url: Api.prepareNativePrint,
params: { id, _t: Date.now() },
});
/** id + 前端生成的 pdfBase64printerName 空则用默认队列 */
export const printPdf = (data: { id: string; printerName?: string; pdfBase64: string; fileName?: string }) =>
defHttp.post({ url: Api.printPdf, data, timeout: 3 * 60 * 1000 });

View File

@@ -7,6 +7,51 @@
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:add'" @click="handleAdd" preIcon="ant-design:plus-outlined"> 新增</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls"> 导出</a-button>
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_raw_material_card:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">导入</j-upload-button>
<a-checkbox v-model:checked="printDotEnabled" style="margin-left: 8px" @change="onPrintDotEnabledChange">
PrintDot 桥接
</a-checkbox>
<a-input
v-model:value="printDotWsUrl"
style="width: 220px; margin-left: 8px"
placeholder="ws://127.0.0.1:1122/ws"
@blur="persistPrintDotConfig"
/>
<a-input-password
v-model:value="printDotKey"
style="width: 130px; margin-left: 8px"
placeholder="密钥(可选)"
autocomplete="new-password"
@blur="persistPrintDotConfig"
/>
<a-button @click="downloadPrintPlugin">下载打印插件</a-button>
<a-select
v-model:value="selectedPrinterName"
:options="printerOptions"
style="width: 220px; margin-left: 8px"
allow-clear
show-search
option-filter-prop="label"
:placeholder="printerSelectPlaceholder"
/>
<a-button @click="() => refreshPrinterOptions(true)">刷新打印机</a-button>
<a-input
v-model:value="manualPrinterName"
style="width: 150px; margin-left: 8px"
placeholder="手动输入打印机名"
@press-enter="addManualPrinter"
/>
<a-button @click="addManualPrinter">添加</a-button>
<a-button
type="primary"
ghost
v-auth="'xslmes:mes_xsl_raw_material_card:edit'"
:loading="printLoading"
:disabled="selectedRowKeys.length === 0 || !printDotEnabled"
@click="handlePrintSelected"
>
<Icon icon="ant-design:printer-outlined" />
打印选中
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
@@ -42,20 +87,73 @@
</BasicTable>
<!-- 表单区域 -->
<MesXslRawMaterialCardModal @register="registerModal" @success="handleSuccess" />
<RawMaterialCardPrintPreviewModal
v-model:open="printPreviewOpen"
:card-id="printPreviewCardId"
:barcode="printPreviewBarcode"
/>
</div>
</template>
<script lang="ts" name="xslmes-mesXslRawMaterialCard" setup>
import { ref, reactive } from 'vue';
import { onMounted, ref, reactive, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { BasicTable, useTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import MesXslRawMaterialCardModal from './components/MesXslRawMaterialCardModal.vue';
import RawMaterialCardPrintPreviewModal from './components/RawMaterialCardPrintPreviewModal.vue';
import { columns, searchFormSchema, superQuerySchema } from './MesXslRawMaterialCard.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, updatePriority } from './MesXslRawMaterialCard.api';
import {
list,
deleteOne,
batchDelete,
getImportUrl,
getExportUrl,
updatePriority,
prepareNativePrint,
} from './MesXslRawMaterialCard.api';
import { useMessage } from '/@/hooks/web/useMessage';
import {
PRINT_TEMPLATE_SELECTED_PRINTER_KEY,
printNativeSchemaViaPrintDot,
} from '/@/views/print/template/utils/printNativeViaPrintDot';
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
import {
fetchPrintDotPrinters,
getPrintDotBridgeConfig,
setPrintDotBridgeConfig,
} from '/@/views/print/template/utils/printDotBridge';
const { createMessage } = useMessage();
const LS_PRINT_DOT_ENABLED = 'qhmes_print_dot_enabled';
const printDotEnabled = ref(localStorage.getItem(LS_PRINT_DOT_ENABLED) !== '0');
const printDotCfg = getPrintDotBridgeConfig();
const printDotWsUrl = ref(printDotCfg.wsUrl);
const printDotKey = ref(printDotCfg.key);
function persistPrintDotConfig() {
setPrintDotBridgeConfig(printDotWsUrl.value, printDotKey.value);
void refreshPrinterOptions(false);
}
function onPrintDotEnabledChange() {
localStorage.setItem(LS_PRINT_DOT_ENABLED, printDotEnabled.value ? '1' : '0');
}
function downloadPrintPlugin() {
const base = import.meta.env.BASE_URL || '/';
const normalizedBase = base.endsWith('/') ? base : `${base}/`;
const url = `${normalizedBase}print-plugin/XSL-PrintDot.exe`;
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'XSL-PrintDot.exe');
link.rel = 'noopener';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
const queryParam = reactive<any>({});
const checkedKeys = ref<Array<string | number>>([]);
const [registerModal, { openModal }] = useModal();
@@ -74,8 +172,11 @@
],
},
actionColumn: {
width: 120,
width: 248,
fixed: 'right',
title: '操作',
dataIndex: 'action',
slots: { customRender: 'action' },
},
beforeFetch: (params) => {
return Object.assign(params, queryParam);
@@ -92,9 +193,207 @@
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const [registerTable, { reload }, { rowSelection, selectedRowKeys, selectedRows }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
/** 打印预览弹窗 */
const printPreviewOpen = ref(false);
const printPreviewCardId = ref<string | null>(null);
const printPreviewBarcode = ref<string | undefined>(undefined);
function handlePrintPreview(record: Recordable) {
printPreviewCardId.value = record.id as string;
printPreviewBarcode.value = record.barcode as string | undefined;
printPreviewOpen.value = true;
}
/** 与打印模板列表共用 localStorage 键,打印机选择保持一致 */
const printerOptions = ref<Array<{ label: string; value: string }>>([]);
const selectedPrinterName = ref<string>('__system_default__');
const manualPrinterName = ref('');
const printLoading = ref(false);
/** 与打印模板列表启用 PrintDot 时一致:仅本机桥接打印机 */
const printerSelectPlaceholder = '选择打印机PrintDot 桥接)';
watch(selectedPrinterName, (v) => {
if (v) {
localStorage.setItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY, v);
}
});
/** 与打印模板列表「PrintDot 桥接」勾选时相同:仅从本机 WebSocket 获取打印机 */
async function refreshPrinterOptions(showMessage = true) {
const optionMap = new Map<string, { label: string; value: string }>();
optionMap.set('__system_default__', { label: '系统默认打印机', value: '__system_default__' });
try {
const dotList = await fetchPrintDotPrinters();
dotList.forEach((p) => {
const name = String(p.name || '').trim();
if (!name) return;
const defMark = p.isDefault ? '(默认)' : '';
optionMap.set(name, { label: `${name}${defMark}`, value: name });
});
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
if (dotList.length) {
createMessage.success(`已从 PrintDot 桥接识别 ${dotList.length} 台打印机`);
} else {
createMessage.warning('PrintDot 已连接但未返回打印机列表');
}
}
} catch (e: unknown) {
if (selectedPrinterName.value && !optionMap.has(selectedPrinterName.value)) {
optionMap.set(selectedPrinterName.value, {
label: `${selectedPrinterName.value}(手动)`,
value: selectedPrinterName.value,
});
}
printerOptions.value = Array.from(optionMap.values());
if (showMessage) {
createMessage.warning(`PrintDot${e instanceof Error ? e.message : String(e)}`);
}
}
}
function addManualPrinter() {
const name = String(manualPrinterName.value || '').trim();
if (!name) return;
const exists = printerOptions.value.some((item) => item.value === name);
if (!exists) {
printerOptions.value = [...printerOptions.value, { label: `${name}(手动)`, value: name }];
}
selectedPrinterName.value = name;
manualPrinterName.value = '';
createMessage.success('已添加打印机');
}
async function executePrint(record: Recordable, options?: { silentSuccess?: boolean }) {
try {
const prep = (await prepareNativePrint(record.id as string)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
const paperWidthMm = Number((prep as any).paperWidthMm ?? 0);
const paperHeightMm = Number((prep as any).paperHeightMm ?? 0);
const paperOrientation = String((prep as any).paperOrientation || '').toLowerCase();
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
// 以模板主表的纸张配置为准,避免 schema 页面尺寸与模板设置不同步导致方向错误/内容缩小。
if (paperWidthMm > 0 && paperHeightMm > 0) {
const orient = paperOrientation === 'landscape' ? 'landscape' : paperOrientation === 'portrait' ? 'portrait' : '';
const normalized =
orient === 'landscape'
? {
width: Math.max(paperWidthMm, paperHeightMm),
height: Math.min(paperWidthMm, paperHeightMm),
}
: orient === 'portrait'
? {
width: Math.min(paperWidthMm, paperHeightMm),
height: Math.max(paperWidthMm, paperHeightMm),
}
: {
width: paperWidthMm,
height: paperHeightMm,
};
schema.page.width = normalized.width;
schema.page.height = normalized.height;
}
/** 与打印模板原生打印一致render → PDF → PrintDot WebSocket */
await printNativeSchemaViaPrintDot({
schema,
data: printData as Record<string, unknown>,
jobName: `原材料卡片-${(record.barcode as string) || record.id}.pdf`,
printerSelection:
selectedPrinterName.value ||
localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY) ||
'__system_default__',
});
if (!options?.silentSuccess) {
createMessage.success('已通过 PrintDot 提交打印');
}
} catch (e: unknown) {
throw new Error(e instanceof Error ? e.message : String(e));
}
}
function handlePrintSelected() {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
return;
}
const rows = selectedRows.value || [];
if (!rows.length) {
createMessage.warning('请至少勾选一条记录后再点击「打印选中」');
return;
}
printLoading.value = true;
const hideLoadingMsg = createMessage.loading(`正在打印 ${rows.length} 条记录,请稍候…`, 0);
(async () => {
let ok = 0;
let firstError = '';
for (const row of rows) {
try {
await executePrint(row, { silentSuccess: true });
ok += 1;
} catch (e: unknown) {
if (!firstError) {
firstError = e instanceof Error ? e.message : String(e);
}
}
}
if (ok === rows.length) {
createMessage.success(`已通过 PrintDot 提交 ${ok} 条打印任务`);
} else {
createMessage.warning(`打印完成:成功 ${ok},失败 ${rows.length - ok}${firstError ? `。首条错误:${firstError}` : ''}`);
}
hideLoadingMsg();
printLoading.value = false;
})();
}
function handlePrintRow(record: Recordable) {
if (!printDotEnabled.value) {
createMessage.warning('请先开启 PrintDot 桥接');
return;
}
printLoading.value = true;
const hideLoadingMsg = createMessage.loading('正在生成 PDF 并提交打印,版面复杂时可能需数十秒,请稍候…', 0);
executePrint(record)
.then(() => {
createMessage.success('已通过 PrintDot 提交打印');
})
.catch((e: unknown) => {
createMessage.error(e instanceof Error ? e.message : String(e));
})
.finally(() => {
hideLoadingMsg();
printLoading.value = false;
});
}
onMounted(() => {
const saved = localStorage.getItem(PRINT_TEMPLATE_SELECTED_PRINTER_KEY);
if (saved) {
selectedPrinterName.value = saved;
}
refreshPrinterOptions(false);
});
function handleSuperQuery(params) {
Object.keys(params).map((k) => {
queryParam[k] = params[k];
@@ -145,6 +444,16 @@
onClick: handleEdit.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_card:edit',
},
{
label: '打印预览',
onClick: handlePrintPreview.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_card:edit',
},
{
label: '打印',
onClick: handlePrintRow.bind(null, record),
auth: 'xslmes:mes_xsl_raw_material_card:edit',
},
];
}

View File

@@ -0,0 +1,211 @@
<template>
<a-modal
v-model:open="innerOpen"
:title="modalTitle"
width="960px"
:footer="null"
destroy-on-close
wrap-class-name="raw-material-card-print-preview-modal"
@cancel="onClose"
>
<a-spin :spinning="loading">
<div v-if="errorText" class="preview-error">{{ errorText }}</div>
<div v-else class="preview-body">
<iframe
v-if="previewHtml"
ref="previewIframeRef"
class="preview-iframe"
title="原材料卡片打印预览"
:srcdoc="previewHtml"
/>
<a-empty v-else-if="!loading" description="暂无预览内容" />
</div>
</a-spin>
<div class="preview-footer">
<a-space>
<a-button @click="innerOpen = false">关闭</a-button>
<a-button type="primary" :disabled="!previewHtml || !!errorText" @click="handleBrowserPrint">
<Icon icon="ant-design:printer-outlined" />
浏览器打印
</a-button>
</a-space>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Icon } from '/@/components/Icon';
import { useMessage } from '/@/hooks/web/useMessage';
import { prepareNativePrint } from '../MesXslRawMaterialCard.api';
import { renderNativePrintHtml } from '/@/views/print/template/native/core/printRenderer';
import { normalizeImportedNativeSchema } from '/@/views/print/template/native/core/nativeSchemaNormalize';
const props = defineProps<{
open: boolean;
/** 卡片主键,有值时拉取模板与业务数据并渲染 */
cardId: string | null;
/** 展示在标题上的条码/说明 */
barcode?: string;
}>();
const emit = defineEmits<{
(e: 'update:open', v: boolean): void;
}>();
const { createMessage } = useMessage();
const innerOpen = computed({
get: () => props.open,
set: (v: boolean) => emit('update:open', v),
});
const modalTitle = computed(() => {
const b = String(props.barcode || '').trim();
return b ? `原材料卡片打印预览(条码:${b}` : '原材料卡片打印预览';
});
const loading = ref(false);
const errorText = ref('');
const previewHtml = ref('');
const previewIframeRef = ref<HTMLIFrameElement | null>(null);
async function loadPreview(id: string) {
loading.value = true;
errorText.value = '';
previewHtml.value = '';
try {
const prep = (await prepareNativePrint(id)) as Record<string, unknown>;
const templateJsonRaw = prep.templateJson as string;
const printData = prep.printData as Record<string, unknown>;
if (!templateJsonRaw) {
throw new Error('模板 JSON 为空,请检查「业务打印绑定」是否已配置');
}
let raw: unknown;
try {
raw = typeof templateJsonRaw === 'string' ? JSON.parse(templateJsonRaw) : templateJsonRaw;
} catch {
throw new Error('模板 JSON 格式错误');
}
const schema = normalizeImportedNativeSchema(raw);
previewHtml.value = await renderNativePrintHtml(schema, printData as Record<string, unknown>);
} catch (e: unknown) {
errorText.value = e instanceof Error ? e.message : String(e);
} finally {
loading.value = false;
}
}
/**
* 在 Modal 内对 iframe 直接 print() 时,打印对话框易被遮罩层级挡住或焦点异常,表现为「点了没反应」。
* 改为在 body 下挂临时 iframe、写入同一套 HTML 再打印,与模板预览常用做法一致。
*/
function handleBrowserPrint() {
const html = previewHtml.value;
if (!html?.trim()) {
createMessage.warning('预览未就绪,请稍后再试');
return;
}
const iframe = document.createElement('iframe');
iframe.setAttribute(
'style',
'position:fixed;left:0;top:0;width:0;height:0;border:0;opacity:0;pointer-events:none;',
);
document.body.appendChild(iframe);
const doc = iframe.contentDocument;
if (!doc) {
document.body.removeChild(iframe);
createMessage.error('无法创建打印文档');
return;
}
try {
doc.open();
doc.write(html);
doc.close();
} catch {
document.body.removeChild(iframe);
createMessage.error('写入打印内容失败');
return;
}
const cleanup = () => {
try {
if (iframe.parentNode) {
document.body.removeChild(iframe);
}
} catch {
// ignore
}
};
const runPrint = () => {
try {
const w = iframe.contentWindow;
if (!w) {
createMessage.error('无法唤起打印窗口');
cleanup();
return;
}
w.focus();
w.print();
/** 关闭打印对话框后移除临时 iframe部分浏览器支持 afterprint */
w.addEventListener('afterprint', cleanup, { once: true });
window.setTimeout(cleanup, 120000);
} catch {
createMessage.error('无法唤起打印,请检查浏览器弹窗/打印权限');
cleanup();
}
};
/** 等待排版与字体后再打印,减少空白页 */
window.setTimeout(runPrint, 100);
}
function onClose() {
errorText.value = '';
previewHtml.value = '';
}
watch(
() => [props.open, props.cardId] as const,
([isOpen, id]) => {
if (isOpen && id) {
void loadPreview(id);
}
if (!isOpen) {
onClose();
}
},
);
</script>
<style lang="less" scoped>
.preview-error {
color: #cf1322;
padding: 8px 0;
}
.preview-body {
min-height: 420px;
max-height: 72vh;
overflow: auto;
border: 1px solid #f0f0f0;
border-radius: 4px;
background: #fafafa;
}
.preview-iframe {
display: block;
width: 100%;
min-height: 400px;
border: 0;
background: #fff;
}
.preview-footer {
margin-top: 16px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
text-align: right;
}
</style>

View File

@@ -23,4 +23,10 @@ public interface IRawMaterialCardService
/// <param name="dryRun">为 true 时仅返回匹配数量、不真正删除(用于「重新拆码」弹窗预提示)</param>
/// <returns>匹配/删除的卡片数量;失败返回 -1</returns>
Task<int> DeleteBySplitDetailIdsAsync(IEnumerable<string> splitDetailIds, bool dryRun = false, CancellationToken ct = default);
/// <summary>
/// 按业务打印绑定生成模板 JSON + 映射后 printData供桌面端打印预览使用。
/// </summary>
/// <returns>(templateJson, printDataJson, errorMessage) 元组errorMessage 非 null 时表示失败</returns>
Task<(string templateJson, string printDataJson, string? errorMessage)> PrepareNativePrintAsync(string id, CancellationToken ct = default);
}

View File

@@ -317,6 +317,39 @@ public class RawMaterialCardService : IRawMaterialCardService, ISingletonDepende
}
}
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/mesXslRawMaterialCard/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 async Task<bool> UpdatePriorityAsync(string id, string priorityPickup, CancellationToken ct = default)
{
if (_networkMonitor.IsOnline)

View File

@@ -5,11 +5,12 @@ using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Windows;
using YY.Admin.Core;
using YY.Admin.Core.Entity;
using YY.Admin.Core.Events;
using YY.Admin.Core.Helper;
using YY.Admin.Core.Entity;
using YY.Admin.Core.Services;
using YY.Admin.Services.Service;
using YY.Admin.Views.Print;
using YY.Admin.Views.RawMaterialCard;
namespace YY.Admin.ViewModels.RawMaterialCard;
@@ -18,6 +19,7 @@ public class RawMaterialCardListViewModel : BaseViewModel
{
private readonly IRawMaterialCardService _cardService;
private readonly IJeecgDictSyncService _dictSyncService;
private readonly IPrintDotService _printDotService;
private SubscriptionToken? _changedToken;
private SubscriptionToken? _syncConflictToken;
@@ -60,6 +62,7 @@ public class RawMaterialCardListViewModel : BaseViewModel
public DelegateCommand AddCommand { get; }
public DelegateCommand<MesXslRawMaterialCard> EditCommand { get; }
public DelegateCommand<MesXslRawMaterialCard> DeleteCommand { get; }
public DelegateCommand<MesXslRawMaterialCard> PrintCommand { get; }
public DelegateCommand<MesXslRawMaterialCard> TogglePriorityCommand { get; }
public DelegateCommand PrevPageCommand { get; }
public DelegateCommand NextPageCommand { get; }
@@ -67,11 +70,13 @@ public class RawMaterialCardListViewModel : BaseViewModel
public RawMaterialCardListViewModel(
IRawMaterialCardService cardService,
IJeecgDictSyncService dictSyncService,
IPrintDotService printDotService,
IContainerExtension container,
IRegionManager regionManager) : base(container, regionManager)
{
_cardService = cardService;
_dictSyncService = dictSyncService;
_printDotService = printDotService;
SearchCommand = new DelegateCommand(async () => { PageNo = 1; await LoadAsync(); });
ResetCommand = new DelegateCommand(async () =>
@@ -83,6 +88,7 @@ public class RawMaterialCardListViewModel : BaseViewModel
AddCommand = new DelegateCommand(async () => await ShowAddDialogAsync());
EditCommand = new DelegateCommand<MesXslRawMaterialCard>(async c => await ShowEditDialogAsync(c));
DeleteCommand = new DelegateCommand<MesXslRawMaterialCard>(async c => await DeleteAsync(c));
PrintCommand = new DelegateCommand<MesXslRawMaterialCard>(async c => await ShowPrintPreviewAsync(c));
TogglePriorityCommand = new DelegateCommand<MesXslRawMaterialCard>(async c => await TogglePriorityAsync(c));
PrevPageCommand = new DelegateCommand(async () => { if (PageNo > 1) { PageNo--; await LoadAsync(); } });
NextPageCommand = new DelegateCommand(async () => { if ((long)PageNo * PageSize < Total) { PageNo++; await LoadAsync(); } });
@@ -255,6 +261,65 @@ public class RawMaterialCardListViewModel : BaseViewModel
}
}
private async Task ShowPrintPreviewAsync(MesXslRawMaterialCard card)
{
if (card?.Id == null) return;
try
{
IsLoading = true;
var (templateJson, printDataJson, errorMessage) = await _cardService.PrepareNativePrintAsync(card.Id);
if (errorMessage != null)
{
Growl.Error(errorMessage);
return;
}
// 构造一个最简的 PrintTemplate 对象用于传入 PrintPreviewWindow供显示标题 / 纸张信息)
var tpl = BuildPrintTemplateFromJson(templateJson);
var win = new PrintPreviewWindow(tpl, templateJson, _printDotService, null, printDataJson)
{
Owner = Application.Current.MainWindow,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
};
win.Show();
}
catch (Exception ex)
{
Growl.Error($"打开打印预览失败:{ex.Message}");
}
finally
{
IsLoading = false;
}
}
private static PrintTemplate BuildPrintTemplateFromJson(string templateJson)
{
try
{
var root = System.Text.Json.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_CARD",
PaperWidthMm = w,
PaperHeightMm = h,
PaperOrientation = w > h ? "横向" : "纵向",
};
}
catch
{
return new PrintTemplate { TemplateName = "原材料卡片", TemplateCode = "MES_RAW_MATERIAL_CARD" };
}
}
protected override void CleanUp()
{
base.CleanUp();

View File

@@ -23,7 +23,8 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
}
public PrintPreviewWindow(PrintTemplate template, string? templateJson,
IPrintDotService? printDotService, string? selectedPrinterName)
IPrintDotService? printDotService, string? selectedPrinterName,
string? initialParamJson = null)
{
InitializeComponent();
_template = template;
@@ -36,7 +37,9 @@ public partial class PrintPreviewWindow : HandyControl.Controls.Window
$"尺寸:{template.PaperWidthMm ?? 210}×{template.PaperHeightMm ?? 297} mm " +
$"方向:{template.PaperOrientation ?? ""}";
TbParamJson.Text = BuildMockParamJson(_templateJson);
TbParamJson.Text = !string.IsNullOrWhiteSpace(initialParamJson)
? initialParamJson!
: BuildMockParamJson(_templateJson);
// 没有 PrintDot 服务时禁用打印相关按钮
if (_printDotService == null)

View File

@@ -143,7 +143,7 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="操作" Width="160">
<DataGridTemplateColumn Header="操作" Width="230">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<hc:UniformSpacingPanel Spacing="6" HorizontalAlignment="Center">
@@ -152,6 +152,11 @@
FontSize="12" Height="26" Padding="8,0"
Command="{Binding DataContext.EditCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
<Button Content="打印"
Style="{StaticResource ButtonInfo}"
FontSize="12" Height="26" Padding="8,0"
Command="{Binding DataContext.PrintCommand, RelativeSource={RelativeSource AncestorType=DataGrid}}"
CommandParameter="{Binding}"/>
<Button Content="删除"
Style="{StaticResource ButtonDanger}"
FontSize="12" Height="26" Padding="8,0"