liudongzhi 1 неделя назад
Родитель
Сommit
be438a5abc

+ 3 - 2
alien-entity/src/main/java/shop/alien/entity/store/PrinterDO.java

@@ -28,15 +28,16 @@ public class PrinterDO {
     // 设备编号SN(每台打印机唯一)
     private String sn;
 
-    // 开发者ID / 商户ID(芯烨=user,佳航=merchantId)
+    // 开发者ID / 商户ID(芯烨=userId,佳航=merchantId ,商鹏=appid)
     private String userId;
 
-    // API密钥 / UserKey / Token
+    // API密钥 / UserKey / Token   芯烨 =开发者秘钥  商鹏 =appsecret  佳航 =?暂时未知
     private String apiKey;
 
     // 备注
     private String remark;
 
+
     // 创建时间
     private LocalDateTime createTime;
     // 店铺ID

+ 86 - 48
alien-store/src/main/java/shop/alien/store/controller/PrintController.java

@@ -1,5 +1,8 @@
 package shop.alien.store.controller;
 
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.GetMapping;
@@ -18,6 +21,7 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 @RestController
 @Slf4j
@@ -41,7 +45,9 @@ public class PrintController {
      */
     @GetMapping("/all")
     public String printAll(@RequestParam(value = "storeId", required = false) Integer storeId,
-                           @RequestParam(value = "receiptType", required = false) Integer receiptType) {
+                           @RequestParam(value = "receiptType", required = false) Integer receiptType,
+                           @RequestParam(value = "data", required = false) String dataJson
+    ) {
         // 默认店铺与票据类型(1:客单, 2:结账单)
         if (storeId == null) storeId = 1;
         if (receiptType == null) receiptType = 2;
@@ -62,65 +68,97 @@ public class PrintController {
             enabled = storeReceiptTemplateConfigService.getDetail(storeId, receiptType, 1);
         }
 
-        // 组装渲染所需数据(示例数据,可替换为真实订单数据)
+        // 组装渲染所需数据:优先使用 dataJson 覆盖默认演示数据
+        Map<String, Object> ctx = defaultContext(receiptType);
+        if (dataJson != null && !dataJson.trim().isEmpty()) {
+            try {
+                JSONObject obj = JSON.parseObject(dataJson);
+                if (obj != null) {
+                    // 扁平 key 直接覆盖
+                    for (String key : obj.keySet()) {
+                        Object val = obj.get(key);
+                        if (!"items".equals(key)) {
+                            ctx.put(key, val);
+                        }
+                    }
+                    // items 列表(可选)
+                    if (obj.containsKey("items")) {
+                        JSONArray arr = obj.getJSONArray("items");
+                        if (arr != null) {
+                            List<Map<String, Object>> items = new ArrayList<>();
+                            for (int i = 0; i < arr.size(); i++) {
+                                JSONObject it = arr.getJSONObject(i);
+                                if (it == null) continue;
+                                Map<String, Object> im = new HashMap<>();
+                                for (String k : it.keySet()) {
+                                    im.put(k, it.get(k));
+                                }
+                                items.add(im);
+                            }
+                            ctx.put("items", items);
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                log.warn("解析 data JSON 失败,将使用默认演示数据: {}", e.getMessage());
+            }
+        }
+
+        // 动态渲染小票
+        String templateJson = enabled != null ? enabled.getTemplateConfigJson() : "{}";
+        // 使用精美排版,宽度 32 更贴合 58mm 打印机(ASCII 32列,中文等宽按2列计算)
+        String content = PrintTemplate.buildFromConfigPretty(templateJson, ctx, 32);
+
+        // 批量打印
+        log.info("打印内容:"+ content );
+        printService.printAll(list, content);
+        return "已下发打印指令,共" + list.size() + "台打印机";
+    }
+
+    private Map<String, Object> defaultContext(Integer receiptType) {
         Map<String, Object> ctx = new HashMap<>();
         ctx.put("ticketName", receiptType != null && receiptType == 2 ? "结账单" : "客单");
-        ctx.put("storeName", "测试门店");
-        ctx.put("orderNo", "ORDER20260406001");
-        ctx.put("tableNo", "A01");
-        ctx.put("peopleCount", 4);
-        ctx.put("diningTime", "2026-04-06 10:00");
-        ctx.put("cashierTime", "2026-04-06 13:00");
-        ctx.put("name", "张大大");
-        ctx.put("phone", "18000000000");
-        ctx.put("beginRemark", "无");
-
-        // 品项
+        ctx.put("storeName", "店铺名称");
+        ctx.put("orderNo", "2026");
+        ctx.put("tableNo", "A1054");
+        ctx.put("peopleCount", 2);
+        ctx.put("diningTime", "2026/01/01 12:00:00~14:00");
+        ctx.put("name", "熊二");
+        ctx.put("phone", "123 6544 7899");
+        ctx.put("cashierTime", "2026/01/01 12:00:00");
+        ctx.put("orderBy", "熊大");
+
         List<Map<String, Object>> items = new ArrayList<>();
         Map<String, Object> i1 = new HashMap<>();
-        i1.put("name", "可乐");
-        i1.put("unitPrice", "3.00");
+        i1.put("name", "锅包肉");
+        i1.put("unitPrice", "10.00");
         i1.put("quantity", 2);
-        i1.put("subtotal", "6.00");
+        i1.put("subtotal", "20.00");
         items.add(i1);
         Map<String, Object> i2 = new HashMap<>();
-        i2.put("name", "方便面");
-        i2.put("unitPrice", "5.00");
-        i2.put("quantity", 1);
-        i2.put("subtotal", "5.00");
+        i2.put("name", "米饭");
+        i2.put("unitPrice", "10.00");
+        i2.put("quantity", 2);
+        i2.put("subtotal", "20.00");
         items.add(i2);
         ctx.put("items", items);
 
-        // 订单价格合计
-        ctx.put("dishPriceTotal", "11.00");
-        ctx.put("serviceFeeTotal", "0.00");
-        ctx.put("otherServiceFeeTotal", "0.00");
-        ctx.put("chargeReason", "");
-        ctx.put("orderTotal", "11.00");
-
-        // 支付与结算信息
-        ctx.put("coupon", "0.00");
-        ctx.put("manualReduction", "0.00");
-        ctx.put("reductionReason", "");
-        ctx.put("settlementMethod", "在线支付");
-        ctx.put("paymentMethod", "微信支付");
-        ctx.put("paymentTotal", "11.00");
-
-        // 底栏信息
-        ctx.put("orderBy", "路通");
-        ctx.put("orderAt", "2026-04-06 10:00");
-        ctx.put("printBy", "系统");
-        ctx.put("printAt", String.valueOf(java.time.LocalDateTime.now()));
-        ctx.put("storeAddress", "XX市XX区XX路100号");
-        ctx.put("storeTel", "020-12345678");
+        ctx.put("dishPriceTotal", "40.00");
+        ctx.put("serviceFeeTotal", "63.00");
+        ctx.put("otherServiceFeeTotal", "1500.00");
+        ctx.put("orderTotal", "1603.00");
 
-        // 动态渲染小票
-        String content = PrintTemplate.buildFromConfig(enabled != null ? enabled.getTemplateConfigJson() : "{}", ctx);
+        ctx.put("coupon", "-10.00");
+        ctx.put("manualReduction", "-20.00");
 
-        // 批量打印
-        log.info("打印内容:"+ content );
-        printService.printAll(list, content);
-        return "已下发打印指令,共" + list.size() + "台打印机";
+        ctx.put("settlementMethod", "收银台");
+        ctx.put("paymentMethod", "支付宝");
+        ctx.put("paymentTotal", "630.00");
+
+        ctx.put("printBy", "吉尾尘");
+        ctx.put("orderAt", "2026/01/01 12:00:00");
+        ctx.put("printAt", "2026/01/01 12:00:00");
+        return ctx;
     }
 
     /**

+ 395 - 0
alien-store/src/main/java/shop/alien/store/service/impl/PrintTemplate.java

@@ -8,6 +8,9 @@ import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 
 public class PrintTemplate {
 
@@ -163,6 +166,398 @@ public class PrintTemplate {
         return sb.toString();
     }
 
+    /**
+     * 按照固定宽度进行精美排版,尽量贴近示例图片的样式(等宽打印机友好)。
+     * - 顶部标题与店铺名居中
+     * - 关键信息按 key:value 左对齐
+     * - 品项表格对齐(名称/单价/数量/小计)
+     * - 分割线区分模块
+     *
+     * 注意:依然基于模板可见字段决定哪些行需要显示;若未配置则不输出。
+     */
+    public static String buildFromConfigPretty(String templateConfigJson, Map<String, Object> context, int lineWidth) {
+        if (lineWidth <= 0) {
+            lineWidth = 30;
+        }
+        if (templateConfigJson == null || templateConfigJson.trim().isEmpty()) {
+            return "";
+        }
+        JSONObject root = JSON.parseObject(templateConfigJson);
+        if (root == null) {
+            return "";
+        }
+        StringBuilder sb = new StringBuilder();
+        JSONArray sections = root.getJSONArray("sections");
+        if (sections == null) {
+            return "";
+        }
+
+        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 可自行调整
+                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));
+                    }
+                }
+                // 整单备注、数量合计等
+                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);
+                    }
+                }
+                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()) {
+                            // 简单相加(均为负数或正负混合时请自行保证格式)
+                            // 此处仅展示由前端直接传入更安全,保留计算注释
+                        }
+                    }
+                }
+                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); // 强调
+                    }
+                }
+                sb.append(thin);
+                continue;
+            }
+
+            // 底栏
+            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);
+            }
+        }
+        sb.append("\n");
+        return sb.toString();
+    }
+
+    private static JSONObject fieldOf(JSONArray fields, String key) {
+        if (fields == null) return null;
+        for (int i = 0; i < fields.size(); i++) {
+            JSONObject f = fields.getJSONObject(i);
+            if (f != null && key.equals(f.getString("key"))) return f;
+        }
+        return null;
+    }
+
+    private static void kvIfVisible(JSONArray fields, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width) {
+        JSONObject f = fieldOf(fields, key);
+        if (f != null && f.getBooleanValue("visible")) {
+            String v = str(ctx.get(key));
+            if (!v.isEmpty()) {
+                sb.append(kv(title, v, width));
+            }
+        }
+    }
+
+    private static void kvIfVisibleDiningTime(JSONArray fields, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width) {
+        JSONObject f = fieldOf(fields, key);
+        if (f != null && f.getBooleanValue("visible")) {
+            String raw = str(ctx.get(key));
+            String v = formatDiningTime(raw);
+            if (!v.isEmpty()) {
+                sb.append(kv(title, v, width));
+            }
+        }
+    }
+
+    private static void kvChild(JSONArray children, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width) {
+        kvChild(children, key, title, ctx, sb, width, false);
+    }
+
+    private static void kvChild(JSONArray children, String key, String title, Map<String, Object> ctx, StringBuilder sb, int width, boolean strong) {
+        if (children == null) return;
+        for (int i = 0; i < children.size(); i++) {
+            JSONObject c = children.getJSONObject(i);
+            if (c == null) continue;
+            if (key.equals(c.getString("key")) && c.getBooleanValue("visible")) {
+                String v = str(ctx.get(key));
+                if (!v.isEmpty()) {
+                    String line = kv(title, v, width);
+                    if (strong) {
+                        sb.append(em(line.trim(), width)).append("\n");
+                    } else {
+                        sb.append(line);
+                    }
+                }
+                break;
+            }
+        }
+    }
+
+    private static String rowItemsHeader(int width, boolean showUnitPrice, boolean showQty, boolean showSubtotal) {
+        // 列宽(右侧列固定宽度),列间距让“单价 与 数量”更开一些
+        int unitW = 6;
+        int qtyW = 4;
+        int subW = 7;
+        int gapBetweenRightCols = 2; // 右侧各列之间的额外间距
+        int gapBeforeRightCols = 1;  // 左侧“品项”和第一个右列之间的间距
+        int rightCols = (showUnitPrice ? 1 : 0) + (showQty ? 1 : 0) + (showSubtotal ? 1 : 0);
+        int gapsTotal = (rightCols > 0 ? gapBeforeRightCols : 0) + Math.max(0, rightCols - 1) * gapBetweenRightCols;
+
+        int nameW = Math.max(6,
+                width
+                        - (showUnitPrice ? unitW : 0)
+                        - (showQty ? qtyW : 0)
+                        - (showSubtotal ? subW : 0)
+                        - gapsTotal);
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(padRight("品项", nameW));
+        if (rightCols > 0) sb.append(repeat(' ', gapBeforeRightCols));
+        boolean needGap = false;
+        if (showUnitPrice) {
+            sb.append(padLeft("单价", unitW));
+            needGap = true;
+        }
+        if (showQty) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft("数量", qtyW));
+            needGap = true;
+        }
+        if (showSubtotal) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft("小计", subW));
+        }
+        sb.append("\n");
+        return sb.toString();
+    }
+
+    private static String rowItem(int width, String name, String unitPrice, String qty, String subtotal) {
+        int unitW = 6;
+        int qtyW = 4;
+        int subW = 7;
+        int gapBetweenRightCols = 2;
+        int gapBeforeRightCols = 1;
+        int rightCols = (unitPrice != null ? 1 : 0) + (qty != null ? 1 : 0) + (subtotal != null ? 1 : 0);
+        int gapsTotal = (rightCols > 0 ? gapBeforeRightCols : 0) + Math.max(0, rightCols - 1) * gapBetweenRightCols;
+
+        int nameW = Math.max(6,
+                width
+                        - (unitPrice != null ? unitW : 0)
+                        - (qty != null ? qtyW : 0)
+                        - (subtotal != null ? subW : 0)
+                        - gapsTotal);
+
+        StringBuilder sb = new StringBuilder();
+        sb.append(padRight(name, nameW));
+        if (rightCols > 0) sb.append(repeat(' ', gapBeforeRightCols));
+        boolean needGap = false;
+        if (unitPrice != null) {
+            sb.append(padLeft(unitPrice, unitW));
+            needGap = true;
+        }
+        if (qty != null) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft(qty, qtyW));
+            needGap = true;
+        }
+        if (subtotal != null) {
+            if (needGap) sb.append(repeat(' ', gapBetweenRightCols));
+            sb.append(padLeft(subtotal, subW));
+        }
+        sb.append("\n");
+        return sb.toString();
+    }
+
+    private static String kv(String k, String v, int width) {
+        String left = k + " ";
+        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 center(String s, int width) {
+        if (s == null) s = "";
+        int w = displayWidth(s);
+        int pad = Math.max(0, (width - w) / 2);
+        String left = repeat(' ', pad) + s;
+        // 填满整行,避免后续拼接错位
+        int remain = Math.max(0, width - displayWidth(left));
+        return left + repeat(' ', remain);
+    }
+
+    private static String em(String s, int width) {
+        // 简单强调:居中(或左右空格加粗线外观)
+        String c = center(s, width);
+        if (c.length() < width) {
+            c = c + repeat(' ', Math.max(0, width - displayWidth(c)));
+        }
+        return c;
+    }
+
+    private static String padLeft(String s, int w) {
+        s = truncateToWidth(str(s), w);
+        int pad = Math.max(0, w - displayWidth(s));
+        return repeat(' ', pad) + s;
+    }
+
+    private static String padRight(String s, int w) {
+        s = truncateToWidth(str(s), w);
+        int pad = Math.max(0, w - displayWidth(s));
+        return s + repeat(' ', pad);
+    }
+
+    private static String repeat(char c, int n) {
+        StringBuilder sb = new StringBuilder();
+        for (int i = 0; i < n; i++) sb.append(c);
+        return sb.toString();
+    }
+
+    /**
+     * 打印机等宽字体对齐:中日韩全角字符按宽度2,ASCII按宽度1
+     */
+    private static int displayWidth(String s) {
+        if (s == null || s.isEmpty()) return 0;
+        int w = 0;
+        for (int i = 0; i < s.length(); i++) {
+            char ch = s.charAt(i);
+            // 基于 East Asian Width 的简化判断:
+            if (isFullWidth(ch)) {
+                w += 2;
+            } else {
+                w += 1;
+            }
+        }
+        return w;
+    }
+
+    private static boolean isFullWidth(char ch) {
+        // CJK Unified Ideographs, Hiragana, Katakana, Fullwidth Forms, Hangul, etc.
+        return (ch >= 0x1100 && ch <= 0x11FF)   // Hangul Jamo
+                || (ch >= 0x2E80 && ch <= 0xA4CF) // CJK Radicals, Kangxi, Yi, etc.
+                || (ch >= 0xAC00 && ch <= 0xD7AF) // Hangul Syllables
+                || (ch >= 0xF900 && ch <= 0xFAFF) // CJK Compatibility Ideographs
+                || (ch >= 0xFE10 && ch <= 0xFE6F) // Vertical forms
+                || (ch >= 0xFF00 && ch <= 0xFF60) // Fullwidth ASCII variants
+                || (ch >= 0xFFE0 && ch <= 0xFFE6); // Fullwidth symbol variants
+    }
+
+    private static String truncateToWidth(String s, int maxWidth) {
+        if (s == null) return "";
+        int w = 0;
+        StringBuilder out = new StringBuilder();
+        for (int i = 0; i < s.length(); i++) {
+            char ch = s.charAt(i);
+            int cw = isFullWidth(ch) ? 2 : 1;
+            if (w + cw > maxWidth) break;
+            out.append(ch);
+            w += cw;
+        }
+        return out.toString();
+    }
+
+    /**
+     * 将就餐时间格式化为:yyyy/MM/dd HH:mm-HH:mm
+     * 支持的原始格式示例:
+     * - 2026/01/01 12:00:00~14:00
+     * - 2026-01-01 12:00:00~14:00
+     * - 2026/01/01 12:00~14:00
+     * - 2026/01/01 12:00
+     */
+    private static String formatDiningTime(String raw) {
+        if (raw == null || raw.trim().isEmpty()) return "";
+        String s = raw.trim();
+        // 正则:日期 + 开始时间 + 分隔(~ 或 - 或 – —) + 结束时间
+        Pattern pRange = Pattern.compile("^(\\d{4}[-/]\\d{2}[-/]\\d{2})\\s+(\\d{2}:\\d{2})(?::\\d{2})?\\s*[~\\-–—]\\s*(\\d{2}:\\d{2})(?::\\d{2})?.*$");
+        Matcher m = pRange.matcher(s);
+        if (m.matches()) {
+            String date = m.group(1).replace('-', '/');
+            String start = m.group(2);
+            String end = m.group(3);
+            return date + " " + start + "-" + end;
+        }
+        // 仅有开始时间
+        Pattern pSingle = Pattern.compile("^(\\d{4}[-/]\\d{2}[-/]\\d{2})\\s+(\\d{2}:\\d{2})(?::\\d{2})?.*$");
+        Matcher m2 = pSingle.matcher(s);
+        if (m2.matches()) {
+            String date = m2.group(1).replace('-', '/');
+            String start = m2.group(2);
+            return date + " " + start;
+        }
+        // 若无法识别,回退为去秒、标准化斜杠的原串
+        s = s.replace('-', '/');
+        s = s.replaceAll("(\\d{2}:\\d{2}):\\d{2}", "$1");
+        return s;
+    }
     private static boolean childVisible(JSONArray children, String key) {
         if (children == null) return false;
         for (int i = 0; i < children.size(); i++) {