云
4 天以前 0f11bea64e963b2231875760bd4e9103e99b8a63
refactor(mes): 重构二维码标签生成逻辑

- 修改二维码配置,将码值显示在下方而非右侧文本区域
- 更新二维码标签图片生成算法,调整布局为二维码在上、码值在下
- 简化标签生成参数,移除复杂的键值对文本叠加功能
- 更新本地环境配置中的追溯基础URL地址
- 调整前端模态框中的二维码下载和打印逻辑以匹配新布局
- 更新追溯页面中部门名称显示和生产明细展示规则
已修改3个文件
175 ■■■■■ 文件已修改
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/application-local.yaml 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-server/src/main/resources/static/trace.html 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-mes/src/main/java/cn/iocoder/yudao/module/mes/framework/qrcode/QrCodeImageUtils.java
@@ -17,10 +17,7 @@
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
@@ -28,9 +25,7 @@
/**
 * 二维码标签图片工具类
 * <p>
 * 生成"二维码 + 右侧可读文本"的合成图片:二维码编码内容由调用方传入(配置了溯源地址时为
 * 带 code 参数的溯源 URL,未配置时为纯码值),右侧叠加关键信息文本,
 * 即使数据库丢失也能从打印图片还原关键信息。
 * 生成二维码在上、码值在下的紧凑标签。二维码内容仍使用调用方传入的溯源链接。
 */
@Slf4j
public final class QrCodeImageUtils {
@@ -46,15 +41,11 @@
    /** 图片四周留白 */
    private static final int PADDING = 16;
    /** 二维码与文本间距 */
    private static final int GAP = 24;
    /** 文本区域宽度 */
    private static final int TEXT_WIDTH = 380;
    private static final int GAP = 4;
    /** 字体大小 */
    private static final int FONT_SIZE = 15;
    private static final int FONT_SIZE = 24;
    /** 行高 */
    private static final int LINE_HEIGHT = 24;
    /** 字段间距 */
    private static final int FIELD_GAP = 6;
    private static final int LINE_HEIGHT = 32;
    /**
     * 中文字体候选(按优先级)。
@@ -76,43 +67,39 @@
    /**
     * 生成二维码标签图片
     *
     * @param code      二维码内容(纯码值)
     * @param keyValues 叠加的可读文本(顺序即绘制顺序)
     * @param code        二维码内容(溯源链接或纯码值)
     * @param displayCode 二维码下方显示的原始码值
     * @return 合成图片
     */
    public static BufferedImage createQrLabel(String code, LinkedHashMap<String, String> keyValues) {
        // 无中文字体时直接失败,避免导出中文为方框的标签
        assertCjkFontAvailable();
    public static BufferedImage createQrLabel(String code, String displayCode) {
        QrConfig config = new QrConfig();
        config.setErrorCorrection(ErrorCorrectionLevel.H); // 打印标签易磨损,用高容错
        config.setMargin(1); // 保证静区,扫码率
        config.setErrorCorrection(ErrorCorrectionLevel.H);
        config.setMargin(4); // 保留二维码静区,保证打印后的识读率
        config.setWidth(QR_SIZE);
        config.setHeight(QR_SIZE);
        BufferedImage qrImage = QrCodeUtil.generate(code, config);
        Font labelFont = new Font(FONT_NAME, Font.BOLD, FONT_SIZE);
        Font valueFont = new Font(FONT_NAME, Font.PLAIN, FONT_SIZE);
        // 第一遍:测量文本块总高度,用于确定图片高度
        int textHeight = measureTextHeight(keyValues, labelFont, valueFont);
        int height = PADDING * 2 + Math.max(QR_SIZE, textHeight);
        int width = PADDING * 2 + QR_SIZE + GAP + TEXT_WIDTH;
        int width = PADDING * 2 + QR_SIZE;
        int height = PADDING * 2 + QR_SIZE + GAP + LINE_HEIGHT;
        BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = image.createGraphics();
        try {
            g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
            g.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
            g.setColor(Color.WHITE);
            g.fillRect(0, 0, width, height);
            // 二维码(垂直居中)
            int qrY = (height - QR_SIZE) / 2;
            g.drawImage(qrImage, PADDING, qrY, null);
            // 可读文本
            int textX = PADDING + QR_SIZE + GAP;
            int textY = (height - textHeight) / 2;
            drawKeyValues(g, keyValues, labelFont, valueFont, textX, textY);
            g.drawImage(qrImage, PADDING, PADDING, null);
            String text = StrUtil.nullToEmpty(displayCode);
            int fontSize = FONT_SIZE;
            g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, fontSize));
            while (g.getFontMetrics().stringWidth(text) > QR_SIZE && fontSize > 1) {
                g.setFont(new Font(Font.SANS_SERIF, Font.BOLD, --fontSize));
            }
            FontMetrics metrics = g.getFontMetrics();
            int textX = (width - metrics.stringWidth(text)) / 2;
            int textY = PADDING + QR_SIZE + GAP
                    + (LINE_HEIGHT - metrics.getHeight()) / 2 + metrics.getAscent();
            g.setColor(Color.BLACK);
            g.drawString(text, textX, textY);
        } finally {
            g.dispose();
        }
@@ -133,7 +120,7 @@
        ZipOutputStream zip = new ZipOutputStream(response.getOutputStream());
        try {
            for (QrLabelInfo label : labels) {
                BufferedImage image = createQrLabel(label.getCode(), label.getKeyValues());
                BufferedImage image = createQrLabel(label.getCode(), label.getFileName());
                zip.putNextEntry(new ZipEntry(label.getFileName() + ".png"));
                ImageIO.write(image, "png", zip);
                zip.closeEntry();
@@ -187,92 +174,6 @@
            throw new IllegalStateException("当前环境没有任何可渲染中文的字体,二维码标签的中文将显示为方框,"
                    + "拒绝生成标签。请在服务器安装中文字体(如 fonts-noto-cjk)后重启。");
        }
    }
    private static int measureTextHeight(LinkedHashMap<String, String> keyValues, Font labelFont, Font valueFont) {
        BufferedImage scratch = new BufferedImage(1, 1, BufferedImage.TYPE_INT_RGB);
        Graphics2D g = scratch.createGraphics();
        try {
            return measureKeyValuesHeight(g, keyValues, labelFont, valueFont);
        } finally {
            g.dispose();
        }
    }
    private static int measureKeyValuesHeight(Graphics2D g, LinkedHashMap<String, String> keyValues,
                                              Font labelFont, Font valueFont) {
        int total = 0;
        for (Map.Entry<String, String> entry : keyValues.entrySet()) {
            String label = StrUtil.emptyToDefault(entry.getKey(), "") + ":";
            String value = StrUtil.emptyToDefault(entry.getValue(), "");
            g.setFont(labelFont);
            int labelWidth = g.getFontMetrics().stringWidth(label);
            g.setFont(valueFont);
            List<String> lines = wrapText(g, value, TEXT_WIDTH - labelWidth);
            total += Math.max(1, lines.size()) * LINE_HEIGHT + FIELD_GAP;
        }
        return total;
    }
    private static void drawKeyValues(Graphics2D g, LinkedHashMap<String, String> keyValues,
                                      Font labelFont, Font valueFont, int x, int y) {
        FontMetrics labelMetrics = g.getFontMetrics(labelFont);
        int baselineOffset = labelMetrics.getAscent(); // drawString 的 y 是基线,先下移一个 ascent
        int baseline = y + baselineOffset;
        for (Map.Entry<String, String> entry : keyValues.entrySet()) {
            String label = StrUtil.emptyToDefault(entry.getKey(), "") + ":";
            String value = StrUtil.emptyToDefault(entry.getValue(), "");
            g.setFont(labelFont);
            int labelWidth = g.getFontMetrics().stringWidth(label);
            g.setColor(Color.BLACK);
            g.drawString(label, x, baseline);
            g.setFont(valueFont);
            int remainingWidth = TEXT_WIDTH - labelWidth;
            List<String> lines = wrapText(g, value, remainingWidth);
            if (!lines.isEmpty()) {
                g.drawString(lines.get(0), x + labelWidth, baseline);
                baseline += LINE_HEIGHT;
                for (int i = 1; i < lines.size(); i++) {
                    g.drawString(lines.get(i), x, baseline);
                    baseline += LINE_HEIGHT;
                }
            } else {
                baseline += LINE_HEIGHT;
            }
            baseline += FIELD_GAP;
        }
    }
    /**
     * 文本按最大宽度换行(逐字测量,兼容中文)
     */
    private static List<String> wrapText(Graphics2D g, String text, int maxWidth) {
        List<String> lines = new ArrayList<>();
        if (StrUtil.isEmpty(text)) {
            return lines;
        }
        if (maxWidth <= 0) {
            lines.add(text);
            return lines;
        }
        StringBuilder current = new StringBuilder();
        FontMetrics metrics = g.getFontMetrics();
        for (int i = 0; i < text.length(); i++) {
            char ch = text.charAt(i);
            String test = current.toString() + ch;
            if (metrics.stringWidth(test) > maxWidth && current.length() > 0) {
                lines.add(current.toString());
                current = new StringBuilder().append(ch);
            } else {
                current.append(ch);
            }
        }
        if (current.length() > 0) {
            lines.add(current.toString());
        }
        return lines;
    }
}
yudao-server/src/main/resources/application-local.yaml
@@ -4,7 +4,7 @@
# 种子标签与 H5 溯源页配置
mes:
  # 追溯网址(二维码内容前缀),需为公众扫码可访问的地址
  trace-base-url: http://192.168.1.7:48080/trace.html
  trace-base-url: http://192.168.0.10:48080/trace.html
  # 生产经营者名称(种子标签四要素之一,标签与溯源页统一取此值;未配置则无法导出标签)
  operator-name: 新疆塔里木河种业股份有限公司
yudao-server/src/main/resources/static/trace.html
@@ -323,7 +323,7 @@
        </div>
        <div class="contact-item">
          <span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.9v3a2 2 0 0 1-2.2 2 19.8 19.8 0 0 1-8.6-3.1 19.5 19.5 0 0 1-6-6A19.8 19.8 0 0 1 2.1 4.2 2 2 0 0 1 4.1 2h3a2 2 0 0 1 2 1.7c.1 1 .4 1.9.7 2.8a2 2 0 0 1-.5 2.1L8.1 9.9a16 16 0 0 0 6 6l1.3-1.3a2 2 0 0 1 2.1-.4c.9.3 1.8.6 2.8.7a2 2 0 0 1 1.7 2z"/></svg></span>
          <span class="txt"><span class="lbl">联系电话</span><div class="val">行政管理部:<a href="tel:09974615802">0997-4615802</a></div></span>
          <span class="txt"><span class="lbl">联系电话</span><div class="val">综合管理部:<a href="tel:09974615802">0997-4615802</a></div></span>
        </div>
        <div class="contact-item">
          <span class="ico"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 9.5 12 3l9 6.5V20a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9.5 22v-6h5v6"/></svg></span>
@@ -501,8 +501,8 @@
    var html = '';
    html += sec('防伪查询', null, fakePanel(d), null);
    html += sec('追溯查询', null, tracePanel(d), null);
    // 生产明细折叠区:国标要求追溯网址可查询「种子加工批次」及「物流销售信息」,故对外展示
    // (加工批次、种子流向、加工流转;折叠不展开)。质检/人员/托盘/袋码/采购来源等内部明细不对外展示,
    // 生产明细折叠区:对外展示种子流向、加工流转,生产批次信息不展示。
    // 质检/人员/托盘/袋码/采购来源等内部明细不对外展示,
    // 如需一并展示,改为 detailSections(d, true) 即可。
    html += detailSections(d, false);
    STATE.innerHTML = html || '<div class="empty-tip">暂无追溯数据</div>';
@@ -592,25 +592,9 @@
  // 区块3~:生产明细(折叠,有数据才展示)
  // full=true 时展示全部明细(质检/人员/托盘/袋码/采购来源);
  // 默认只展示国标要求的「加工批次」与「物流销售(种子流向)」信息,其余区块不对外展示。
  // 默认只展示物流销售(种子流向)信息,其余区块不对外展示。
  function detailSections(d, full) {
    var html = '';
    // 生产批次信息(国标要求:追溯网址应可查询种子加工批次)
    // 未录入批次时不展示空区块,避免给出"无加工批次信息"的错误暗示
    if (d.batchBound !== false) {
      var batchBody =
        kv('批次号', text(d.batchCode)) +
        kv('批次状态', d.batchStatusName ? statusBadge({ v: [d.batchStatusName, 'blue'] }, 'v') : text(d.batchStatus)) +
        kv('生产日期', fmtDate(d.produceDate)) +
        kv('产线', text(d.lineName || d.lineCode)) +
        kv('计划数量', fmtNum(d.planQuantity)) +
        kv('已生成袋码', fmtNum(d.bagQuantity)) +
        kv('已生成托盘', fmtNum(d.palletQuantity)) +
        kv('入库时间', fmtDate(d.inboundTime)) +
        kv('备注', text(d.remark));
      html += sec('生产批次信息', null, batchBody, { collapsible: true, open: false });
    }
    // 质检信息(仅完整模式展示)
    if (full && d.quality) {