فهرست منبع

佳博打票机连通调试+样式调整

liudongzhi 1 روز پیش
والد
کامیت
1d8a953b2d

+ 21 - 4
alien-store/src/main/java/shop/alien/store/controller/PrintController.java

@@ -4,6 +4,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.GetMapping;
 import org.springframework.web.bind.annotation.PostMapping;
@@ -206,11 +207,27 @@ public class PrintController {
     }
 
     /**
-     * 查询所有打印机
-     * 访问地址:http://localhost:8080/print/list
+     * 查询店铺所有打印机
      */
     @GetMapping("/list")
-    public Object list() {
-        return printerMapper.selectList(null);
+    public Object list(Integer storeId) {
+        QueryWrapper<PrinterDO> queryWrapper = new QueryWrapper<>();
+        queryWrapper.lambda().eq(PrinterDO::getDelFlag, "0").eq(PrinterDO::getStoreId,storeId);
+        return printerMapper.selectList(queryWrapper);
     }
+
+
+    /**
+     * 解绑打印机
+     */
+    @PostMapping ("/delete")
+    public String delete(@RequestBody PrinterDO body) {
+        PrinterDO printer = new PrinterDO();
+        printer.setId(body.getId());
+        printer.setDelFlag("1");
+        int a =printerMapper.updateById(printer);
+        return a>0?"解绑成功":"解绑失败";
+    }
+
+
 }

+ 27 - 4
alien-store/src/main/java/shop/alien/store/service/impl/PrintService.java

@@ -21,10 +21,22 @@ import java.net.URLEncoder;
 import java.nio.charset.StandardCharsets;
 import java.security.MessageDigest;
 import java.util.*;
+import java.util.regex.Pattern;
 
 @Slf4j
 @Service
 public class PrintService {
+    /**
+     * 芯烨/商鹏等走各自 HTML/标签解析;佳博自由打印走 ESC/POS,需在正文前下发初始化,避免机型默认倍宽/倍高、行距过大。
+     */
+    private static final String JIABO_ESC_POS_PREFIX =
+            "\u001B@"       // ESC @ 初始化
+                    + "\u001B!\0"   // ESC ! 0 标准 ASCII 模式、取消加粗/倍宽等
+                    + "\u001D!\0"   // GS ! 0 字符宽高 1×1(取消倍宽倍高)
+                    + "\u001B2";    // ESC 2 默认行间距
+
+    private static final Pattern BOLD_TAG = Pattern.compile("(?i)</?B>");
+
     // HTTP请求工具
     private final OkHttpClient client = new OkHttpClient();
     // JSON请求头
@@ -42,6 +54,15 @@ public class PrintService {
         }
     }
 
+    /** 佳博专用:ESC/POS 初始化 + 去掉云打印可能原样输出的加粗标签 */
+    private static String buildJiaBoMsgDetail(String content) {
+        if (content == null) {
+            return JIABO_ESC_POS_PREFIX;
+        }
+        String plain = BOLD_TAG.matcher(content).replaceAll("");
+        return JIABO_ESC_POS_PREFIX + plain;
+    }
+
     /**
      * 打印重试机制
      * @param printer 打印机
@@ -107,6 +128,7 @@ public class PrintService {
         String memberCode = p.getUserId(); // 假设 userId 是 memberCode
         String apiKey = p.getApiKey();     // 假设 apiKey 是签名 Key
         String deviceId = p.getSn();       // 打印机 SN 作为设备 ID
+        String msgDetail = buildJiaBoMsgDetail(content);
 
         try {
 //            String reqTime = String.valueOf(System.currentTimeMillis());
@@ -123,10 +145,11 @@ public class PrintService {
             params.put("memberCode", memberCode);
             params.put("deviceID", deviceId); // 必须传入设备 ID
             params.put("token", "");          // 如果有 token 请填入
-            params.put("mode", "2");          // 2 通常代表云打印模式
-            params.put("msgDetail", content); // 传入实际打印内容
-            params.put("cmdType", "ESC");     // 指令类型
-            params.put("charset", "4");       // 字符集:
+            params.put("mode", "2");          // mode=2 自由格式(ESC/POS)
+            params.put("msgDetail", msgDetail);
+            params.put("cmdType", "ESC");
+            // UTF-8 与 Java UrlEncode 一致;若个别机型中文乱码可改为 "1"(GB18030) 并在云端核对编码说明
+            params.put("charset", "4");
 //            params.put("msgNo", String.valueOf(System.currentTimeMillis())); // 唯一流水号
             params.put("times", "");         // 打印份数
             params.put("reprint", "0");       // 0-不重打

+ 380 - 92
alien-store/src/main/java/shop/alien/store/service/impl/PrintTemplate.java

@@ -3,6 +3,11 @@ package shop.alien.store.service.impl;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
+import com.fasterxml.jackson.core.JsonParser;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.HashMap;
@@ -14,6 +19,8 @@ import java.util.regex.Pattern;
 
 public class PrintTemplate {
 
+    private static final Logger log = LoggerFactory.getLogger(PrintTemplate.class);
+
     /**
      * 兼容旧的静态示例生成方法(保留)
      */
@@ -51,7 +58,7 @@ public class PrintTemplate {
         if (templateConfigJson == null || templateConfigJson.trim().isEmpty()) {
             return "";
         }
-        JSONObject root = JSON.parseObject(templateConfigJson);
+        JSONObject root = parseTemplateRoot(templateConfigJson);
         if (root == null) {
             return "";
         }
@@ -146,10 +153,20 @@ public class PrintTemplate {
                         }
                     }
                 } else {
-                    // 普通文本字段
+                    // 普通文本字段(票据名/店铺名默认仅输出值;模板 field.printTitle 为 true 时带 titleName)
                     Object value = context.get(key);
                     if (value != null && !"".equals(String.valueOf(value).trim())) {
-                        sb.append(titleName).append(":").append(value).append("\n");
+                        if (isHeaderTicketField(key) && !field.getBooleanValue("printTitle")) {
+                            String v = String.valueOf(value);
+                            String a = nvl(field.getString("align"));
+                            if ("right".equalsIgnoreCase(a)) {
+                                sb.append(alignRight(v, 32)).append("\n");
+                            } else {
+                                sb.append(center(v, 32)).append("\n");
+                            }
+                        } else {
+                            sb.append(titleName).append(":").append(value).append("\n");
+                        }
                     }
                 }
 
@@ -167,13 +184,13 @@ public class PrintTemplate {
     }
 
     /**
-     * 按照固定宽度进行精美排版,尽量贴近示例图片的样式(等宽打印机友好)。
-     * - 顶部标题与店铺名居中
-     * - 关键信息按 key:value 左对齐
-     * - 品项表格对齐(名称/单价/数量/小计)
-     * - 分割线区分模块
+     * 按照固定宽度进行精美排版(等宽打印机友好)。
+     * 根据模板 JSON 中每个 field 的 titleName、visible、align、bold、fontSize、divider、blankLines 等动态排版;
+     * 字段顺序与模板 sections[].fields 数组顺序一致。
+     * 票据名(ticketName)、店铺名(storeName) 与其它标量字段相同,受上述属性控制;默认不打印 titleName 文案,
+     * 若需在纸上显示「票据名称:xxx」,可在对应 field 上设 {@code "printTitle": true}。
      *
-     * 注意:依然基于模板可见字段决定哪些行需要显示;若未配置则不输出
+     * 加粗:部分云打印接口支持 {@code <B>...</B>},若打印机原样输出可关闭模板中的 bold
      */
     public static String buildFromConfigPretty(String templateConfigJson, Map<String, Object> context, int lineWidth) {
         if (lineWidth <= 0) {
@@ -182,7 +199,7 @@ public class PrintTemplate {
         if (templateConfigJson == null || templateConfigJson.trim().isEmpty()) {
             return "";
         }
-        JSONObject root = JSON.parseObject(templateConfigJson);
+        JSONObject root = parseTemplateRoot(templateConfigJson);
         if (root == null) {
             return "";
         }
@@ -195,116 +212,299 @@ public class PrintTemplate {
         String thin = repeat('-', lineWidth) + "\n";
         String thick = repeat('=', lineWidth) + "\n";
 
-        // 顶部:票据名称、店铺名称(若模板开启)
-        String ticketName = str(context.get("ticketName"));
-        String storeName = str(context.get("storeName"));
-        if (!ticketName.isEmpty()) sb.append(center(ticketName, lineWidth)).append("\n");
-        if (!storeName.isEmpty()) sb.append(center(storeName, lineWidth)).append("\n");
-        if (sb.length() > 0) sb.append(thick);
-
-        // 逐段渲染
+        // 逐段渲染(顺序、样式均由模板决定)
         for (int i = 0; i < sections.size(); i++) {
             JSONObject section = sections.getJSONObject(i);
             if (section == null) continue;
             JSONArray fields = section.getJSONArray("fields");
             if (fields == null) continue;
 
-            // 基础信息与头部
-            if (Objects.equals(section.getString("key"), "basic")) {
-                // 关键行顺序参考示例
-                kvIfVisible(fields, "orderNo", "单号", context, sb, lineWidth);
-                kvIfVisible(fields, "tableNo", "桌号", context, sb, lineWidth);
-                kvIfVisible(fields, "peopleCount", "人数", context, sb, lineWidth);
-                kvIfVisibleDiningTime(fields, "diningTime", "就餐时间", context, sb, lineWidth);
-                kvIfVisible(fields, "name", "姓名", context, sb, lineWidth);
-                kvIfVisible(fields, "phone", "电话", context, sb, lineWidth);
-                kvIfVisible(fields, "cashierTime", "结账时间", context, sb, lineWidth);
-                kvIfVisible(fields, "orderBy", "收银员", context, sb, lineWidth); // 若模板中使用 print/cashier 可自行调整
+            String sectionKey = section.getString("key");
+
+            // 基础信息:票据名/店铺名等与单号等同一套 field 样式,并在头部与后续内容之间插入粗分割线(兼容旧版视觉)
+            if (Objects.equals(sectionKey, "basic")) {
+                boolean headerEmitted = false;
+                boolean thickInserted = false;
+                for (int j = 0; j < fields.size(); j++) {
+                    JSONObject field = fields.getJSONObject(j);
+                    if (field == null || !field.getBooleanValue("visible")) {
+                        continue;
+                    }
+                    String fk = field.getString("key");
+                    if (!isHeaderTicketField(fk) && headerEmitted && !thickInserted) {
+                        sb.append(thick);
+                        thickInserted = true;
+                    }
+                    boolean wrote = appendPrettyScalarField(field, context, sb, lineWidth, thin);
+                    if (wrote && isHeaderTicketField(fk)) {
+                        headerEmitted = true;
+                    }
+                }
+                if (headerEmitted && !thickInserted) {
+                    sb.append(thick);
+                }
                 sb.append(thin);
                 continue;
             }
 
             // 品项信息
-            if (Objects.equals(section.getString("key"), "itemInfo")) {
-                JSONObject itemField = fieldOf(fields, "item");
-                if (itemField != null && itemField.getBooleanValue("visible")) {
-                    JSONArray children = itemField.getJSONArray("children");
-                    boolean showUnitPrice = childVisible(children, "unitPrice");
-                    boolean showQuantity = childVisible(children, "quantity");
-                    boolean showSubtotal = childVisible(children, "subtotal");
-
-                    // 表头
-                    sb.append(rowItemsHeader(lineWidth, showUnitPrice, showQuantity, showSubtotal));
-
-                    @SuppressWarnings("unchecked")
-                    List<Map<String, Object>> items = (List<Map<String, Object>>) context.getOrDefault("items", new ArrayList<>());
-                    for (Map<String, Object> item : items) {
-                        String name = str(item.get("name"));
-                        String unitPrice = showUnitPrice ? str(item.get("unitPrice")) : null;
-                        String qty = showQuantity ? str(item.get("quantity")) : null;
-                        String subtotal = showSubtotal ? str(item.get("subtotal")) : null;
-                        sb.append(rowItem(lineWidth, name, unitPrice, qty, subtotal));
+            if (Objects.equals(sectionKey, "itemInfo")) {
+                for (int j = 0; j < fields.size(); j++) {
+                    JSONObject field = fields.getJSONObject(j);
+                    if (field == null || !field.getBooleanValue("visible")) {
+                        continue;
                     }
-                }
-                // 整单备注、数量合计等
-                kvIfVisible(fields, "wholeRemark", "整单备注", context, sb, lineWidth);
-                kvIfVisible(fields, "countSubtotal", "数量合计", context, sb, lineWidth);
-
-                // 订单价格分组
-                JSONObject orderPrice = fieldOf(fields, "orderPrice");
-                if (orderPrice != null && orderPrice.getBooleanValue("visible")) {
-                    JSONArray children = orderPrice.getJSONArray("children");
-                    if (children != null) {
-                        kvChild(children, "dishPriceTotal", "菜品价格合计", context, sb, lineWidth);
-                        kvChild(children, "serviceFeeTotal", "服务费合计", context, sb, lineWidth);
-                        kvChild(children, "otherServiceFeeTotal", "其他费用合计", context, sb, lineWidth);
-                        kvChild(children, "orderTotal", "订单合计", context, sb, lineWidth);
+                    String fk = field.getString("key");
+                    if ("item".equals(fk)) {
+                        JSONArray children = field.getJSONArray("children");
+                        boolean showUnitPrice = childVisible(children, "unitPrice");
+                        boolean showQuantity = childVisible(children, "quantity");
+                        boolean showSubtotal = childVisible(children, "subtotal");
+                        String secTitle = nvl(field.getString("titleName"));
+                        if (!secTitle.isEmpty()) {
+                            appendPrettyRawLine(secTitle, field, sb, lineWidth);
+                        }
+                        sb.append(rowItemsHeader(lineWidth, showUnitPrice, showQuantity, showSubtotal));
+                        @SuppressWarnings("unchecked")
+                        List<Map<String, Object>> items = (List<Map<String, Object>>) context.getOrDefault("items", new ArrayList<>());
+                        for (Map<String, Object> item : items) {
+                            String name = str(item.get("name"));
+                            String unitPrice = showUnitPrice ? str(item.get("unitPrice")) : null;
+                            String qty = showQuantity ? str(item.get("quantity")) : null;
+                            String subtotal = showSubtotal ? str(item.get("subtotal")) : null;
+                            sb.append(rowItem(lineWidth, name, unitPrice, qty, subtotal));
+                        }
+                        appendFieldAffixes(field, sb, thin);
+                        continue;
+                    }
+                    if ("orderPrice".equals(fk)) {
+                        appendPrettyChildrenGroup(field, context, sb, lineWidth, thin, "orderTotal");
+                        continue;
                     }
+                    appendPrettyScalarField(field, context, sb, lineWidth, thin);
                 }
                 sb.append(thin);
                 continue;
             }
 
             // 结算信息
-            if (Objects.equals(section.getString("key"), "settlement")) {
-                JSONObject paymentDiscount = fieldOf(fields, "paymentDiscount");
-                if (paymentDiscount != null && paymentDiscount.getBooleanValue("visible")) {
-                    JSONArray children = paymentDiscount.getJSONArray("children");
-                    if (children != null) {
-                        kvChild(children, "coupon", "优惠券", context, sb, lineWidth);
-                        kvChild(children, "manualReduction", "手动减免", context, sb, lineWidth);
-                        // 优惠合计:若有 coupon/manualReduction 则可由前端计算,也可由后端传入
-                        String coupon = str(context.get("coupon"));
-                        String manualReduction = str(context.get("manualReduction"));
-                        if (!coupon.isEmpty() || !manualReduction.isEmpty()) {
-                            // 简单相加(均为负数或正负混合时请自行保证格式)
-                            // 此处仅展示由前端直接传入更安全,保留计算注释
-                        }
+            if (Objects.equals(sectionKey, "settlement")) {
+                for (int j = 0; j < fields.size(); j++) {
+                    JSONObject field = fields.getJSONObject(j);
+                    if (field == null || !field.getBooleanValue("visible")) {
+                        continue;
                     }
-                }
-                JSONObject paymentInfo = fieldOf(fields, "paymentInfo");
-                if (paymentInfo != null && paymentInfo.getBooleanValue("visible")) {
-                    JSONArray children = paymentInfo.getJSONArray("children");
-                    if (children != null) {
-                        kvChild(children, "settlementMethod", "结算方式", context, sb, lineWidth, false);
-                        kvChild(children, "paymentMethod", "支付方式", context, sb, lineWidth, false);
-                        kvChild(children, "paymentTotal", "支付合计", context, sb, lineWidth, true); // 强调
+                    String fk = field.getString("key");
+                    if ("paymentDiscount".equals(fk) || "paymentInfo".equals(fk)) {
+                        appendPrettyChildrenGroup(field, context, sb, lineWidth, thin, "paymentTotal");
+                        continue;
                     }
+                    appendPrettyScalarField(field, context, sb, lineWidth, thin);
                 }
                 sb.append(thin);
                 continue;
+            } else {
+                // 底栏及其他自定义 section:按字段顺序输出
+                for (int j = 0; j < fields.size(); j++) {
+                    JSONObject field = fields.getJSONObject(j);
+                    if (field == null || !field.getBooleanValue("visible")) {
+                        continue;
+                    }
+                    String fk = field.getString("key");
+                    if ("item".equals(fk) || "orderPrice".equals(fk) || "paymentDiscount".equals(fk) || "paymentInfo".equals(fk)) {
+                        continue;
+                    }
+                    appendPrettyScalarField(field, context, sb, lineWidth, thin);
+                }
             }
+        }
+        sb.append("\n\n");
+        return sb.toString();
+    }
 
-            // 底栏
-            if (Objects.equals(section.getString("key"), "footer")) {
-                kvIfVisible(fields, "orderBy", "下单人", context, sb, lineWidth);
-                kvIfVisible(fields, "orderAt", "下单时间", context, sb, lineWidth);
-                kvIfVisible(fields, "printBy", "打印人", context, sb, lineWidth);
-                kvIfVisible(fields, "printAt", "打印时间", context, sb, lineWidth);
+    private static boolean isHeaderTicketField(String key) {
+        return "ticketName".equals(key) || "storeName".equals(key);
+    }
+
+    /**
+     * 普通标量字段:一行展示 titleName:value,并按 align / bold / fontSize 等处理。
+     * @return 是否有内容输出(非空值)
+     */
+    private static boolean appendPrettyScalarField(JSONObject field, Map<String, Object> ctx, StringBuilder sb, int lineWidth, String thin) {
+        String key = field.getString("key");
+        if (key == null) {
+            return false;
+        }
+        String titleName = nvl(field.getString("titleName"));
+        String raw = str(ctx.get(key));
+        if ("diningTime".equals(key)) {
+            raw = formatDiningTime(raw);
+        }
+        if (raw.isEmpty()) {
+            return false;
+        }
+        // 票据名、店铺名默认只打业务值(不拼 titleName),样式仍完全来自模板:visible / align / bold / fontSize / divider / blankLines
+        // 若需在纸上带出「票据名称:」等标签,将模板中对应 field 增加 "printTitle": true
+        String titleForLine = titleName;
+        if (isHeaderTicketField(key) && !field.getBooleanValue("printTitle")) {
+            titleForLine = "";
+        }
+        JSONObject styleField = effectiveStyleForHeaderScalar(field, key, titleForLine);
+        appendPrettyKeyValueLine(titleForLine, raw, styleField, sb, lineWidth);
+        appendFieldAffixes(field, sb, thin);
+        return true;
+    }
+
+    /**
+     * 票据名、店铺名仅输出内容时,小票惯例为居中;模板 align 为 {@code right} 时仍右对齐;
+     * 带 {@code printTitle} 展示「标题:值」时完全沿用模板 align。
+     */
+    private static JSONObject effectiveStyleForHeaderScalar(JSONObject field, String key, String titleForLine) {
+        if (!isHeaderTicketField(key) || !titleForLine.isEmpty()) {
+            return field;
+        }
+        String a = nvl(field.getString("align"));
+        if ("right".equalsIgnoreCase(a)) {
+            return field;
+        }
+        JSONObject copy = JSON.parseObject(field.toJSONString());
+        copy.put("align", "center");
+        return copy;
+    }
+
+    private static void appendPrettyKeyValueLine(String titleName, String value, JSONObject style, StringBuilder sb, int lineWidth) {
+        String align = nvl(style.getString("align"));
+        if (align.isEmpty()) {
+            align = "left";
+        }
+        boolean bold = style.getBooleanValue("bold");
+        String fontSize = nvl(style.getString("fontSize"));
+        if (fontSize.isEmpty()) {
+            fontSize = "normal";
+        }
+
+        if ("large".equalsIgnoreCase(fontSize)) {
+            sb.append("\n");
+        }
+
+        String core;
+        if ("left".equals(align)) {
+            if (titleName.isEmpty()) {
+                core = truncateToWidth(value, lineWidth);
+            } else {
+                core = kvColon(titleName, value, lineWidth).trim();
+            }
+        } else {
+            String full = titleName.isEmpty() ? value : (titleName + ":" + value);
+            if ("center".equals(align)) {
+                core = center(full, lineWidth).trim();
+            } else if ("right".equals(align)) {
+                core = alignRight(full, lineWidth).trim();
+            } else {
+                core = titleName.isEmpty() ? truncateToWidth(value, lineWidth) : kvColon(titleName, value, lineWidth).trim();
             }
         }
-        sb.append("\n");
-        return sb.toString();
+        // small:纯文本无法缩字号,行前缩进以区分(与 appendPrettyRawLine 一致)
+        if ("small".equalsIgnoreCase(fontSize)) {
+            core = "  " + core;
+        }
+        if (bold) {
+            core = "<B>" + core + "</B>";
+        }
+        sb.append(core).append("\n");
+    }
+
+    /** 不带「标题:值」结构的纯文本一行(如品项分组标题) */
+    private static void appendPrettyRawLine(String text, JSONObject style, StringBuilder sb, int lineWidth) {
+        JSONObject s = style == null ? new JSONObject() : style;
+        String align = nvl(s.getString("align"));
+        if (align.isEmpty()) {
+            align = "left";
+        }
+        boolean bold = s.getBooleanValue("bold");
+        String fontSize = nvl(s.getString("fontSize"));
+        if (fontSize.isEmpty()) {
+            fontSize = "normal";
+        }
+        if ("large".equalsIgnoreCase(fontSize)) {
+            sb.append("\n");
+        }
+        String core;
+        if ("center".equals(align)) {
+            core = center(text, lineWidth).trim();
+        } else if ("right".equals(align)) {
+            core = alignRight(text, lineWidth).trim();
+        } else {
+            core = truncateToWidth(text, lineWidth);
+        }
+        if ("small".equalsIgnoreCase(fontSize)) {
+            core = "  " + core;
+        }
+        if (bold) {
+            core = "<B>" + core + "</B>";
+        }
+        sb.append(core).append("\n");
+    }
+
+    private static void appendFieldAffixes(JSONObject field, StringBuilder sb, String thin) {
+        if (field.getBooleanValue("divider")) {
+            sb.append(thin);
+        }
+        int blankLines = field.getIntValue("blankLines");
+        for (int b = 0; b < blankLines; b++) {
+            sb.append("\n");
+        }
+    }
+
+    /**
+     * 带子项的分组字段:按 children 顺序渲染;子项 key 为 divider 时输出分割线。
+     * emphasizeTotalKey 对应的行使用居中强调(兼容原「订单合计 / 支付合计」样式)。
+     */
+    private static void appendPrettyChildrenGroup(JSONObject groupField, Map<String, Object> ctx, StringBuilder sb,
+                                                  int lineWidth, String thin, String emphasizeTotalKey) {
+        JSONArray children = groupField.getJSONArray("children");
+        if (children == null) {
+            return;
+        }
+        for (int k = 0; k < children.size(); k++) {
+            JSONObject child = children.getJSONObject(k);
+            if (child == null || !child.getBooleanValue("visible")) {
+                continue;
+            }
+            String subKey = child.getString("key");
+            if ("divider".equals(subKey)) {
+                sb.append(thin);
+                continue;
+            }
+            String subTitle = nvl(child.getString("titleName"));
+            String val = str(ctx.get(subKey));
+            if (val.isEmpty()) {
+                continue;
+            }
+            boolean emphasize = emphasizeTotalKey != null && emphasizeTotalKey.equals(subKey);
+            if (emphasize) {
+                String line = kvColon(subTitle, val, lineWidth).trim();
+                sb.append(em(line, lineWidth)).append("\n");
+            } else {
+                appendPrettyKeyValueLine(subTitle, val, child, sb, lineWidth);
+            }
+        }
+        appendFieldAffixes(groupField, sb, thin);
+    }
+
+    private static String kvColon(String title, String v, int width) {
+        String left = title + ":";
+        String right = v;
+        int maxRight = Math.max(0, width - displayWidth(left));
+        String rightTrim = truncateToWidth(right, maxRight);
+        int gap = Math.max(0, width - displayWidth(left) - displayWidth(rightTrim));
+        return left + repeat(' ', gap) + rightTrim + "\n";
+    }
+
+    private static String alignRight(String s, int width) {
+        int w = displayWidth(s);
+        int pad = Math.max(0, width - w);
+        return repeat(' ', pad) + s;
     }
 
     private static JSONObject fieldOf(JSONArray fields, String key) {
@@ -558,6 +758,94 @@ public class PrintTemplate {
         s = s.replaceAll("(\\d{2}:\\d{2}):\\d{2}", "$1");
         return s;
     }
+    /**
+     * 解析门店票据模板 JSON。库中内容可能含尾随逗号、被二次序列化成字符串、或仅 Jackson 能容忍的格式;
+     * 解析失败时返回 null(调用方得到空内容),并打日志,避免打印接口直接 500。
+     */
+    private static JSONObject parseTemplateRoot(String raw) {
+        if (raw == null) {
+            return null;
+        }
+        String s = raw.trim();
+        if (s.isEmpty()) {
+            return null;
+        }
+        if (s.charAt(0) == '\uFEFF') {
+            s = s.substring(1);
+        }
+
+        JSONObject o = tryFastjsonParseObject(s);
+        if (o != null) {
+            return o;
+        }
+
+        String noTrailing = stripTrailingCommas(s);
+        if (!noTrailing.equals(s)) {
+            o = tryFastjsonParseObject(noTrailing);
+            if (o != null) {
+                log.info("票据模板 JSON 已去除尾随逗号后解析成功");
+                return o;
+            }
+            s = noTrailing;
+        }
+
+        o = tryJacksonToFastjsonObject(s);
+        if (o != null) {
+            log.info("票据模板 JSON 已通过 Jackson 兼容解析成功");
+            return o;
+        }
+
+        log.warn("票据模板 JSON 无法解析,跳过模板渲染。若需打印请检查库中 template_config_json 是否为合法 JSON。");
+        if (log.isDebugEnabled()) {
+            log.debug("模板 JSON 原文(截断前800字符): {}", s.length() > 800 ? s.substring(0, 800) + "..." : s);
+        }
+        return null;
+    }
+
+    /** 支持根节点为对象,或整段被引号包成字符串且内层仍是 JSON 字符串的情况 */
+    private static JSONObject tryFastjsonParseObject(String s) {
+        try {
+            Object v = JSON.parse(s);
+            int guard = 0;
+            while (v instanceof String && guard++ < 6) {
+                v = JSON.parse(((String) v).trim());
+            }
+            if (v instanceof JSONObject) {
+                return (JSONObject) v;
+            }
+        } catch (Exception ignored) {
+            // fall through
+        }
+        return null;
+    }
+
+    /** 去掉 ,] 与 ,} 形式的尾随逗号,便于兼容手写/导出 JSON */
+    private static String stripTrailingCommas(String json) {
+        if (json == null) {
+            return null;
+        }
+        String cur = json;
+        String prev;
+        do {
+            prev = cur;
+            cur = cur.replaceAll(",(\\s*[}\\]])", "$1");
+        } while (!cur.equals(prev));
+        return cur;
+    }
+
+    /** Jackson 对尾随逗号等更宽松,再转回 Fastjson 对象供后续逻辑使用 */
+    private static JSONObject tryJacksonToFastjsonObject(String s) {
+        try {
+            ObjectMapper om = new ObjectMapper();
+            // 使用 JsonParser.Feature,避免部分版本无 ObjectMapper.enable(JsonReadFeature) 重载导致编译失败
+            om.configure(JsonParser.Feature.ALLOW_TRAILING_COMMA, true);
+            Object tree = om.readValue(s, Object.class);
+            return JSON.parseObject(JSON.toJSONString(tree));
+        } catch (Exception e) {
+            return null;
+        }
+    }
+
     private static boolean childVisible(JSONArray children, String key) {
         if (children == null) return false;
         for (int i = 0; i < children.size(); i++) {

+ 7 - 3
alien-store/src/main/java/shop/alien/store/service/impl/StoreReceiptTemplateConfigServiceImpl.java

@@ -368,8 +368,8 @@ public class StoreReceiptTemplateConfigServiceImpl implements StoreReceiptTempla
 
     private JSONArray createBasicFields() {
         JSONArray fields = new JSONArray();
-        fields.add(createTextField("ticketName", "票据名称", true));
-        fields.add(createTextField("storeName", "店铺名称", true));
+        fields.add(createTextField("ticketName", "票据名称", true, "center"));
+        fields.add(createTextField("storeName", "店铺名称", true, "center"));
         fields.add(createTextField("orderNo", "单号", true));
         fields.add(createTextField("tableNo", "桌号", true));
         fields.add(createTextField("peopleCount", "人数", true));
@@ -424,12 +424,16 @@ public class StoreReceiptTemplateConfigServiceImpl implements StoreReceiptTempla
     }
 
     private JSONObject createTextField(String key, String titleName, boolean visible) {
+        return createTextField(key, titleName, visible, "left");
+    }
+
+    private JSONObject createTextField(String key, String titleName, boolean visible, String align) {
         JSONObject field = new JSONObject();
         field.put("key", key);
         field.put("titleName", titleName);
         field.put("visible", visible);
         field.put("fontSize", "normal");
-        field.put("align", "left");
+        field.put("align", align);
         field.put("bold", false);
         field.put("divider", false);
         field.put("blankLines", 0);