Commit 58d2e61cad2119c92ef9b920b0bacd13bb4dded5

Authored by 杨鑫
1 parent 876db888

最新代码

Showing 43 changed files with 4184 additions and 1102 deletions
打印机安卓基座/README.md
... ... @@ -13,4 +13,6 @@
13 13 - 再执行:
14 14 - `native-fast-printer/sync-to-uniapp.sh`
15 15  
  16 +**1.2.0+**:新增 `printCommandBytes`(JS 将 TSC 指令 Base64 后交给佳博 SDK 写出),供整页光栅等路径走基座下发,避免仅依赖 JS 经典蓝牙慢发。
  17 +
16 18 当前目录只放这套原生打印基座代码,不再混放其他 SDK、参考项目或历史实验代码。
... ...
打印机安卓基座/native-fast-printer/android-src/src/com/foodlabel/nativeprinter/NativeFastPrinterModule.java
... ... @@ -13,11 +13,13 @@ import com.taobao.weex.bridge.JSCallback;
13 13 import java.util.concurrent.ExecutorService;
14 14 import java.util.concurrent.Executors;
15 15  
  16 +import android.util.Base64;
  17 +
16 18 import io.dcloud.feature.uniapp.common.UniModule;
17 19  
18 20 public class NativeFastPrinterModule extends UniModule {
19 21 private static final String BACKEND = "gprinter-sdk";
20   - private static final String PLUGIN_VERSION = "1.1.0";
  22 + private static final String PLUGIN_VERSION = "1.2.0";
21 23 private static final Object LOCK = new Object();
22 24 private static final ExecutorService PRINT_EXECUTOR = Executors.newSingleThreadExecutor();
23 25 private static final NativePrintDebugState DEBUG_STATE = new NativePrintDebugState(BACKEND, PLUGIN_VERSION);
... ... @@ -132,6 +134,71 @@ public class NativeFastPrinterModule extends UniModule {
132 134 });
133 135 }
134 136  
  137 + /**
  138 + * 将 JS 侧已生成的 TSC/指令字节(Base64)经佳博 SDK 写出。
  139 + * 用于整页光栅等路径,避免再走 JS 经典蓝牙 socket(慢、易超时)。
  140 + */
  141 + @JSMethod(uiThread = false)
  142 + public void printCommandBytes(JSONObject params, JSCallback callback) {
  143 + String deviceId = SafeJson.getString(params, "deviceId", "");
  144 + String deviceName = SafeJson.getString(params, "deviceName", "");
  145 + String base64 = SafeJson.getString(params, "base64", "");
  146 +
  147 + if (base64 == null || base64.trim().isEmpty()) {
  148 + if (callback != null) {
  149 + callback.invoke(errorResult(9011012, "base64 is empty.").toJsonString());
  150 + }
  151 + return;
  152 + }
  153 +
  154 + PluginResult connectResult = ensureConnected(deviceId, deviceName);
  155 + if (!connectResult.success) {
  156 + if (callback != null) {
  157 + callback.invoke(connectResult.toJsonString());
  158 + }
  159 + return;
  160 + }
  161 +
  162 + DEBUG_STATE.setStage("printCommandBytes:queued");
  163 + if (callback != null) {
  164 + callback.invoke(debugResult(PluginResult.ok(true, DEBUG_STATE.getCurrentDeviceId(), DEBUG_STATE.getCurrentDeviceName(), "printCommandBytes:queued")).toJsonString());
  165 + }
  166 +
  167 + PRINT_EXECUTOR.execute(new Runnable() {
  168 + @Override
  169 + public void run() {
  170 + try {
  171 + byte[] bytes = Base64.decode(base64, Base64.DEFAULT);
  172 + if (bytes == null || bytes.length == 0) {
  173 + DEBUG_STATE.setError("decoded bytes empty");
  174 + DEBUG_STATE.setStage("printCommandBytes:error");
  175 + return;
  176 + }
  177 + long writeStarted = System.currentTimeMillis();
  178 + synchronized (LOCK) {
  179 + if (!BLUETOOTH_TRANSPORT.isConnected()) {
  180 + errorResult(9011005, "Bluetooth printer transport is not ready.");
  181 + return;
  182 + }
  183 + DEBUG_STATE.setStage("write-raw-command");
  184 + boolean ok = BLUETOOTH_TRANSPORT.write(bytes);
  185 + if (!ok) {
  186 + errorResult(9011011, "Printer writeDataImmediately returned false.");
  187 + return;
  188 + }
  189 + }
  190 + DEBUG_STATE.setWriteMs(Math.max(0L, System.currentTimeMillis() - writeStarted));
  191 + DEBUG_STATE.markPrinted(System.currentTimeMillis());
  192 + DEBUG_STATE.setStage("printCommandBytes:ok");
  193 + DEBUG_STATE.clearError();
  194 + } catch (Throwable e) {
  195 + DEBUG_STATE.setError(ThrowableUtils.unwrap(e));
  196 + DEBUG_STATE.setStage("printCommandBytes:error");
  197 + }
  198 + }
  199 + });
  200 + }
  201 +
135 202 private PluginResult ensureConnected(String deviceId, String deviceName) {
136 203 synchronized (LOCK) {
137 204 PluginResult result = BLUETOOTH_TRANSPORT.ensureConnected(deviceId, deviceName, DEBUG_STATE);
... ...
打印机安卓基座/native-fast-printer/android-src/src/com/foodlabel/nativeprinter/template/NativeTemplateCommandBuilder.java
... ... @@ -20,6 +20,11 @@ import java.util.regex.Pattern;
20 20  
21 21 public final class NativeTemplateCommandBuilder {
22 22 private static final double DESIGN_DPI = 96.0;
  23 + /**
  24 + * 与 JS 光栅路径 clearTopRasterRows 等效:热敏头可印区相对模板顶边常有一小段空白,
  25 + * 全部为 0 时顶部中文位图/TEXT 易被裁切;略下移与整页光栅观感一致。
  26 + */
  27 + private static final int LABEL_TOP_MARGIN_DOTS = 18;
23 28 private static final int TEXT_PADDING_DOTS = 6;
24 29 private static final int DEFAULT_THRESHOLD = 180;
25 30 private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{\\{\\s*([\\w.-]+)\\s*\\}\\}");
... ... @@ -83,8 +88,12 @@ public final class NativeTemplateCommandBuilder {
83 88 } else {
84 89 nativeTextCount++;
85 90 int scale = resolveTextScale(getDouble(config, "fontSize", 14), dpi);
  91 + if ("TEXT_PRICE".equals(type)) {
  92 + // 价格行与普通文案保持接近视觉粗细,避免看起来偏“粗黑”。
  93 + scale = Math.max(1, scale - 1);
  94 + }
86 95 int x = resolveTextX(align, getDouble(element, "x", 0), getDouble(element, "width", 0), dpi, text, scale);
87   - int y = pxToDots(getDouble(element, "y", 0), dpi);
  96 + int y = yDots(getDouble(element, "y", 0), dpi);
88 97 int rotation = "vertical".equalsIgnoreCase(getString(element, "rotation", "horizontal")) ? 90 : 0;
89 98 addLine(out, "TEXT " + x + "," + y + ",\"TSS24.BF2\"," + rotation + "," + scale + "," + scale + ",\"" + escapeTscString(text) + "\"");
90 99 }
... ... @@ -92,26 +101,51 @@ public final class NativeTemplateCommandBuilder {
92 101 }
93 102  
94 103 if ("QRCODE".equals(type)) {
95   - qrCodeCount++;
  104 + String sourceLike = getString(config, "src", getString(config, "data", getString(config, "url", "")));
  105 + if (isImageLikeSource(sourceLike)) {
  106 + BitmapPatch patch = createImagePatch(element, config, dpi, sourceLike);
  107 + if (patch != null) {
  108 + imagePatchCount++;
  109 + writeBitmapPatch(out, patch);
  110 + }
  111 + continue;
  112 + }
96 113 String value = resolveElementDataValue(type, config, data);
97 114 if (value.isEmpty()) continue;
  115 + if (isImageLikeSource(value)) {
  116 + BitmapPatch patch = createImagePatch(element, config, dpi, value);
  117 + if (patch != null) {
  118 + imagePatchCount++;
  119 + writeBitmapPatch(out, patch);
  120 + }
  121 + continue;
  122 + }
  123 + qrCodeCount++;
98 124 String level = normalizeQrLevel(getString(config, "errorLevel", "M"));
99 125 int x = pxToDots(getDouble(element, "x", 0), dpi);
100   - int y = pxToDots(getDouble(element, "y", 0), dpi);
  126 + int y = yDots(getDouble(element, "y", 0), dpi);
101 127 int size = resolveQrModuleSize(getDouble(element, "width", 0), getDouble(element, "height", 0), dpi, value, level);
102 128 addLine(out, "QRCODE " + x + "," + y + "," + level + "," + size + ",A,0,\"" + escapeTscString(value) + "\"");
103 129 continue;
104 130 }
105 131  
106 132 if ("BARCODE".equals(type)) {
107   - barcodeCount++;
108 133 String value = resolveElementDataValue(type, config, data);
109 134 if (value.isEmpty()) continue;
  135 + barcodeCount++;
110 136 int x = pxToDots(getDouble(element, "x", 0), dpi);
111   - int y = pxToDots(getDouble(element, "y", 0), dpi);
  137 + int y = yDots(getDouble(element, "y", 0), dpi);
112 138 int height = Math.max(20, pxToDots(getDouble(element, "height", 0), dpi));
113 139 int readable = getBoolean(config, "showText", true) ? 1 : 0;
114 140 String orientation = getString(config, "orientation", getString(element, "rotation", "horizontal"));
  141 + if ("vertical".equalsIgnoreCase(orientation)) {
  142 + BitmapPatch patch = createVerticalBarcodePatch(element, config, dpi, value);
  143 + if (patch != null) {
  144 + imagePatchCount++;
  145 + writeBitmapPatch(out, patch);
  146 + }
  147 + continue;
  148 + }
115 149 int rotation = "vertical".equalsIgnoreCase(orientation) ? 90 : 0;
116 150 int narrow = clamp(getDouble(element, "width", 0) / Math.max(40.0, value.length() * 6.0), 1, 4);
117 151 int wide = clamp(getDouble(element, "width", 0) / Math.max(24.0, value.length() * 3.0), 2, 6);
... ... @@ -132,7 +166,7 @@ public final class NativeTemplateCommandBuilder {
132 166 if ("BLANK".equals(type) && "line".equalsIgnoreCase(getString(element, "border", ""))) {
133 167 lineCount++;
134 168 int x = pxToDots(getDouble(element, "x", 0), dpi);
135   - int y = pxToDots(getDouble(element, "y", 0), dpi);
  169 + int y = yDots(getDouble(element, "y", 0), dpi);
136 170 int width = Math.max(1, pxToDots(getDouble(element, "width", 0), dpi));
137 171 int height = Math.max(1, pxToDots(getDouble(element, "height", 1), dpi));
138 172 addLine(out, "BAR " + x + "," + y + "," + width + "," + height);
... ... @@ -171,7 +205,8 @@ public final class NativeTemplateCommandBuilder {
171 205 } catch (Exception ignored) {
172 206 }
173 207 }
174   - return prefix + raw + suffix;
  208 + raw = trimLeadingCurrencyIfPrefixed(raw, prefix);
  209 + return normalizePriceCurrencySymbol(prefix + raw + suffix);
175 210 }
176 211 if (hasText && "TEXT_STATIC".equals(type)) {
177 212 return applyTemplateData(configText, data);
... ... @@ -266,6 +301,10 @@ public final class NativeTemplateCommandBuilder {
266 301  
267 302 private static boolean shouldRasterizeText(String text, String type) {
268 303 if (text == null || text.isEmpty()) return false;
  304 + if ("TEXT_PRICE".equals(type) && isSimplePriceLikeText(text)) {
  305 + // 价格行优先走原生 TEXT:避免位图二值化导致的糊边/左侧杂点。
  306 + return false;
  307 + }
269 308 for (int i = 0; i < text.length(); i++) {
270 309 char c = text.charAt(i);
271 310 if (c < 32 || c > 126) {
... ... @@ -281,6 +320,36 @@ public final class NativeTemplateCommandBuilder {
281 320 }
282 321 }
283 322  
  323 + private static boolean isSimplePriceLikeText(String text) {
  324 + String s = text == null ? "" : text.trim();
  325 + if (s.isEmpty()) return false;
  326 + // 允许货币符号 + 数字/小数点/逗号/空格,统一按原生字体输出。
  327 + return s.matches("^[¥¥$€£]?\\s*[-+]?\\d+(?:[.,]\\d{1,2})?\\s*$");
  328 + }
  329 +
  330 + private static String normalizePriceCurrencySymbol(String value) {
  331 + if (value == null || value.isEmpty()) return "";
  332 + return value.replace('¥', '¥');
  333 + }
  334 +
  335 + private static String trimLeadingCurrencyIfPrefixed(String raw, String prefix) {
  336 + if (raw == null || raw.isEmpty()) return "";
  337 + String p = prefix == null ? "" : prefix.trim();
  338 + if (p.isEmpty()) return raw;
  339 + char c = p.charAt(0);
  340 + if (c != '¥' && c != '¥' && c != '$' && c != '€' && c != '£') return raw;
  341 + String s = raw.trim();
  342 + while (!s.isEmpty()) {
  343 + char ch = s.charAt(0);
  344 + if (ch == '¥' || ch == '¥' || ch == '$' || ch == '€' || ch == '£') {
  345 + s = s.substring(1).trim();
  346 + continue;
  347 + }
  348 + break;
  349 + }
  350 + return s;
  351 + }
  352 +
284 353 private static BitmapPatch createTextPatch(JSONObject element, String type, JSONObject config, String text, int dpi, String align) {
285 354 int contentWidth = Math.max(8, pxToDots(getDouble(element, "width", 0), dpi));
286 355 Paint paint = new Paint();
... ... @@ -290,7 +359,8 @@ public final class NativeTemplateCommandBuilder {
290 359 paint.setColor(Color.BLACK);
291 360 int fontSizeDots = Math.max(14, pxToDots(getDouble(config, "fontSize", 14), dpi));
292 361 paint.setTextSize(fontSizeDots);
293   - boolean bold = "bold".equalsIgnoreCase(getString(config, "fontWeight", "")) || "TEXT_PRICE".equals(type);
  362 + /** 不再对 TEXT_PRICE 强制加粗:fakeBold + 粗体会糊边、measureText 偏窄,右对齐时左侧易出现杂点 */
  363 + boolean bold = "bold".equalsIgnoreCase(getString(config, "fontWeight", ""));
294 364 paint.setFakeBoldText(bold);
295 365 paint.setTypeface(bold ? Typeface.create(Typeface.SANS_SERIF, Typeface.BOLD) : Typeface.SANS_SERIF);
296 366  
... ... @@ -327,21 +397,112 @@ public final class NativeTemplateCommandBuilder {
327 397 if ("center".equals(align)) {
328 398 drawX = horizontalPadding + Math.max(0, (drawableWidth - lineWidth) / 2f);
329 399 } else if ("right".equals(align)) {
330   - drawX = horizontalPadding + Math.max(0, drawableWidth - lineWidth);
  400 + /** ¥ 等字符 measureText 常偏窄,右对齐时真实笔画会略凸向左,易在位图左缘挤出杂点 */
  401 + float w = lineWidth;
  402 + if ("TEXT_PRICE".equals(type)) {
  403 + w += Math.max(2f, paint.getTextSize() * 0.12f);
  404 + }
  405 + drawX = horizontalPadding + Math.max(0, drawableWidth - w);
331 406 }
332 407 float baseline = topOffset + i * lineHeight - metrics.top;
333 408 canvas.drawText(line, drawX, baseline, paint);
334 409 }
335 410  
336 411 BitmapPatch patch = new BitmapPatch(Math.max(0, pxToDots(getDouble(element, "x", 0), dpi) - horizontalPadding),
337   - Math.max(0, pxToDots(getDouble(element, "y", 0), dpi) - verticalPadding),
  412 + Math.max(0, yDots(getDouble(element, "y", 0), dpi) - verticalPadding),
338 413 invertMonochrome(bitmapToMonochrome(bitmap, DEFAULT_THRESHOLD)));
339 414 bitmap.recycle();
340 415 return patch;
341 416 }
342 417  
343 418 private static BitmapPatch createImagePatch(JSONObject element, JSONObject config, int dpi) {
344   - String source = getString(config, "src", getString(config, "data", getString(config, "url", "")));
  419 + return createImagePatch(element, config, dpi, null);
  420 + }
  421 +
  422 + private static BitmapPatch createVerticalBarcodePatch(JSONObject element, JSONObject config, int dpi, String value) {
  423 + int width = ensureMultipleOf8(Math.max(8, pxToDots(getDouble(element, "width", 0), dpi)));
  424 + int height = Math.max(12, pxToDots(getDouble(element, "height", 0), dpi));
  425 + Bitmap outputBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
  426 + Canvas canvas = new Canvas(outputBitmap);
  427 + canvas.drawColor(Color.WHITE);
  428 +
  429 + int pad = 2;
  430 + boolean showText = getBoolean(config, "showText", true);
  431 + int textBandWidth = (showText && value != null && !value.isEmpty()) ? Math.max(10, (int) Math.round(width * 0.18)) : 0;
  432 + int barAreaWidth = Math.max(8, width - textBandWidth - pad * 2);
  433 + int innerHeight = Math.max(10, height - pad * 2);
  434 +
  435 + int[] modules = barcodeModulesFromValue(value);
  436 + Paint barPaint = new Paint();
  437 + barPaint.setAntiAlias(false);
  438 + barPaint.setColor(Color.BLACK);
  439 + if (modules.length > 0) {
  440 + double moduleH = (double) innerHeight / (double) modules.length;
  441 + double cursorY = pad;
  442 + for (int i = 0; i < modules.length; i++) {
  443 + if (modules[i] == 1) {
  444 + float top = (float) cursorY;
  445 + float bottom = (float) (cursorY + Math.max(0.7, moduleH * 0.86));
  446 + canvas.drawRect(pad, top, pad + barAreaWidth, bottom, barPaint);
  447 + }
  448 + cursorY += moduleH;
  449 + }
  450 + }
  451 +
  452 + if (showText && value != null && !value.isEmpty() && textBandWidth > 0) {
  453 + Paint txt = new Paint();
  454 + txt.setAntiAlias(true);
  455 + txt.setColor(Color.BLACK);
  456 + int font = Math.max(9, Math.min(11, (int) Math.floor(textBandWidth * 0.75)));
  457 + txt.setTextSize(font);
  458 + txt.setTextAlign(Paint.Align.CENTER);
  459 + float cx = width - textBandWidth / 2f;
  460 + float cy = height / 2f;
  461 + canvas.save();
  462 + // 竖排文本按模板端习惯:从下到上
  463 + canvas.rotate(-90f, cx, cy);
  464 + Paint.FontMetrics fm = txt.getFontMetrics();
  465 + float baseline = cy - (fm.ascent + fm.descent) / 2f;
  466 + canvas.drawText(value, cx, baseline, txt);
  467 + canvas.restore();
  468 + }
  469 +
  470 + BitmapPatch patch = new BitmapPatch(
  471 + pxToDots(getDouble(element, "x", 0), dpi),
  472 + yDots(getDouble(element, "y", 0), dpi),
  473 + bitmapToMonochrome(outputBitmap, (int) getDouble(config, "threshold", DEFAULT_THRESHOLD))
  474 + );
  475 + outputBitmap.recycle();
  476 + return patch;
  477 + }
  478 +
  479 + private static int[] barcodeModulesFromValue(String value) {
  480 + String s = value == null ? "" : value.trim();
  481 + if (s.isEmpty()) return new int[0];
  482 + java.util.ArrayList<Integer> m = new java.util.ArrayList<>();
  483 + // quiet + start
  484 + int[] start = new int[]{1, 0, 1, 0, 1, 0, 1, 0};
  485 + for (int v : start) m.add(v);
  486 + for (int i = 0; i < s.length(); i++) {
  487 + int code = s.charAt(i) & 0xFF;
  488 + int key = (code ^ (i * 13) ^ (s.length() * 7)) & 0x1F;
  489 + for (int b = 4; b >= 0; b--) {
  490 + m.add((key >> b) & 1);
  491 + }
  492 + m.add(0);
  493 + }
  494 + int[] stop = new int[]{1, 0, 1, 1, 0, 1, 0, 1};
  495 + for (int v : stop) m.add(v);
  496 + int[] out = new int[m.size()];
  497 + for (int i = 0; i < m.size(); i++) out[i] = m.get(i);
  498 + return out;
  499 + }
  500 +
  501 + private static BitmapPatch createImagePatch(JSONObject element, JSONObject config, int dpi, String sourceOverride) {
  502 + String source = sourceOverride;
  503 + if (source == null || source.isEmpty()) {
  504 + source = getString(config, "src", getString(config, "data", getString(config, "url", "")));
  505 + }
345 506 if (source.isEmpty()) return null;
346 507 Bitmap sourceBitmap = decodeBitmap(source);
347 508 if (sourceBitmap == null) return null;
... ... @@ -377,7 +538,7 @@ public final class NativeTemplateCommandBuilder {
377 538 canvas.drawBitmap(scaledBitmap, targetLeft, targetTop, paint);
378 539  
379 540 BitmapPatch patch = new BitmapPatch(pxToDots(getDouble(element, "x", 0), dpi),
380   - pxToDots(getDouble(element, "y", 0), dpi),
  541 + yDots(getDouble(element, "y", 0), dpi),
381 542 bitmapToMonochrome(outputBitmap, (int) getDouble(config, "threshold", DEFAULT_THRESHOLD)));
382 543  
383 544 scaledBitmap.recycle();
... ... @@ -386,6 +547,18 @@ public final class NativeTemplateCommandBuilder {
386 547 return patch;
387 548 }
388 549  
  550 + private static boolean isImageLikeSource(String source) {
  551 + if (source == null) return false;
  552 + String s = source.trim().toLowerCase();
  553 + if (s.isEmpty()) return false;
  554 + if (s.startsWith("data:image/")) return true;
  555 + if (s.startsWith("file://")) return true;
  556 + if (s.startsWith("/picture/") || s.startsWith("picture/")) return true;
  557 + if (s.startsWith("/static/") || s.startsWith("static/")) return true;
  558 + if (s.matches("^[a-z]:[\\\\/].*")) return true;
  559 + return s.matches(".*\\.(png|jpe?g|gif|webp|bmp)(\\?.*)?$");
  560 + }
  561 +
389 562 private static Bitmap decodeBitmap(String source) {
390 563 try {
391 564 if (source.startsWith("data:image/")) {
... ... @@ -599,6 +772,11 @@ public final class NativeTemplateCommandBuilder {
599 772 return Math.max(0, (int) Math.round(value * dpi / DESIGN_DPI));
600 773 }
601 774  
  775 + /** 模板 y(px)→ 点阵 y,并加上与光栅路径一致的上边距,减轻顶部裁切 */
  776 + private static int yDots(double yPx, int dpi) {
  777 + return Math.max(0, pxToDots(yPx, dpi) + LABEL_TOP_MARGIN_DOTS);
  778 + }
  779 +
602 780 private static double toMillimeter(double value, String unit) {
603 781 if ("mm".equalsIgnoreCase(unit)) return value;
604 782 if ("cm".equalsIgnoreCase(unit)) return value * 10;
... ...
美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json
... ... @@ -2,8 +2,8 @@
2 2 "version" : "1.0",
3 3 "configurations" : [
4 4 {
5   - "customPlaygroundType" : "device",
6   - "playground" : "standard",
  5 + "customPlaygroundType" : "local",
  6 + "playground" : "custom",
7 7 "type" : "uni-app:app-android"
8 8 }
9 9 ]
... ...
美国版/Food Labeling Management App UniApp/docs/native-fast-printer-custom-base.md 0 → 100644
  1 +# native-fast-printer 自定义基座接入(Windows)
  2 +
  3 +本文用于当前项目一键接入 `native-fast-printer`,并打出可安装的 Android 自定义基座。
  4 +
  5 +## 0. 现状确认
  6 +
  7 +本项目已具备以下前提:
  8 +
  9 +- `src/manifest.json` 已声明 `app-plus.nativePlugins.native-fast-printer`
  10 +- `nativeplugins/native-fast-printer` 目录可用
  11 +- 打印逻辑已在运行时检查插件可用性
  12 +
  13 +你只需要执行同步 + 在 HBuilderX 打自定义基座。
  14 +
  15 +## 1. 同步插件到 uniapp 目录
  16 +
  17 +在仓库根目录执行:
  18 +
  19 +```powershell
  20 +powershell -ExecutionPolicy Bypass -File ".\美国版\Food Labeling Management App UniApp\scripts\sync-native-fast-printer.ps1"
  21 +```
  22 +
  23 +成功后会输出 `SRC AAR` / `DST AAR` 的大小与时间戳。
  24 +
  25 +## 2. HBuilderX 打包自定义基座(Android)
  26 +
  27 +1. 用 HBuilderX 打开目录:`美国版/Food Labeling Management App UniApp`
  28 +2. 进入 `发行 -> 原生App-云打包`
  29 +3. 选择 `Android`
  30 +4. 关键项:
  31 + - 勾选 `自定义基座`
  32 + - 勾选 `使用本地 nativeplugins`
  33 + - 确认插件列表包含 `native-fast-printer`
  34 +5. 开始打包并下载生成的 APK(自定义基座)
  35 +6. 卸载设备旧包,安装这个新 APK
  36 +
  37 +## 3. 安装后验收
  38 +
  39 +1. 打开蓝牙页,连接目标打印机
  40 +2. 执行测试打印
  41 +3. 进入正式打印页打印 1 张
  42 +4. 若插件成功生效,不应再出现 `NATIVE_FAST_PRINTER_PLUGIN_NOT_FOUND`
  43 +
  44 +## 4. 常见问题
  45 +
  46 +- 打包后仍提示插件不存在
  47 + 多数是安装了旧包。请先卸载旧包再安装新 APK。
  48 +
  49 +- HBuilderX 未识别 native plugin
  50 + 检查 `nativeplugins/native-fast-printer/package.json` 是否存在且 `id` 为 `native-fast-printer`。
  51 +
  52 +- 需要更新原生代码
  53 + 修改 `打印机安卓基座/native-fast-printer` 后,重新生成 AAR 并再次执行第 1 步同步。
... ...
美国版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/README.md
... ... @@ -13,12 +13,28 @@
13 13 ```js
14 14 const printer = uni.requireNativePlugin('native-fast-printer')
15 15 ```
16   -fan
  16 +
17 17 ## 方法
18 18 - `connect(params, callback)`
19 19 - `disconnect(callback)`
20 20 - `isConnected(callback)`
21 21 - `printTemplate(params, callback)`
22 22  
  23 +## 源码位置
  24 +- 当前目录是源码主目录
  25 +- `美国版/Food Labeling Management App UniApp/nativeplugins/native-fast-printer/` 是同步后的 uni-app 打包镜像
  26 +
  27 +## 目录结构
  28 +- `android-src/src/com/foodlabel/nativeprinter/`
  29 + - `NativeFastPrinterModule.java`:uni-app 原生模块入口
  30 + - `transport/`:蓝牙连接与 SDK 传输层
  31 + - `template/`:系统模板 JSON → TSC 指令
  32 + - `debug/`:调试状态与统计信息
  33 + - `support/`:结果对象、JSON 读取、异常展开
  34 +- `android/`:编译产物 AAR
  35 +- `sync-to-uniapp.sh`:同步到 uni-app 打包镜像
  36 +
23 37 ## 说明
24   -修改源码后可执行 `android-src/build-aar.sh` 重新生成 AAR。
  38 +1. 修改源码后执行 `android-src/build-aar.sh`
  39 +2. 再执行 `sync-to-uniapp.sh`
  40 +3. 重新打包 uni-app 自定义基座
... ...
美国版/Food Labeling Management App UniApp/package.json
... ... @@ -2,6 +2,7 @@
2 2 "name": "food-labeling-management-app-uniapp",
3 3 "version": "0.0.0",
4 4 "scripts": {
  5 + "sync:native-fast-printer": "powershell -ExecutionPolicy Bypass -File scripts/sync-native-fast-printer.ps1",
5 6 "dev:custom": "uni -p",
6 7 "dev:h5": "uni",
7 8 "dev:h5:ssr": "uni --ssr",
... ...
美国版/Food Labeling Management App UniApp/scripts/sync-native-fast-printer.ps1 0 → 100644
  1 +$ErrorActionPreference = "Stop"
  2 +
  3 +$appRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path
  4 +$repoRoot = Split-Path -Parent (Split-Path -Parent $appRoot)
  5 +$dstPluginRoot = Join-Path $appRoot "nativeplugins\native-fast-printer"
  6 +$manifestPath = Join-Path $appRoot "src\manifest.json"
  7 +
  8 +Write-Host "[1/4] Validate source plugin files..."
  9 +$sourceAarItem = Get-ChildItem -LiteralPath $repoRoot -Recurse -File -Filter "native_fast_printer-release.aar" |
  10 + Where-Object { $_.FullName -notlike "*\nativeplugins\native-fast-printer\android\*" } |
  11 + Select-Object -First 1
  12 +if ($null -eq $sourceAarItem) {
  13 + throw "AAR not found under repository root: $repoRoot"
  14 +}
  15 +
  16 +$srcPluginRoot = Split-Path -Parent (Split-Path -Parent $sourceAarItem.FullName)
  17 +$srcAar = $sourceAarItem.FullName
  18 +$srcPkg = Join-Path $srcPluginRoot "package.json"
  19 +$srcReadme = Join-Path $srcPluginRoot "README.md"
  20 +if (!(Test-Path -LiteralPath $srcPkg)) { throw "package.json not found: $srcPkg" }
  21 +if (!(Test-Path -LiteralPath $srcReadme)) { throw "README.md not found: $srcReadme" }
  22 +
  23 +Write-Host "[2/4] Sync files into uniapp nativeplugins..."
  24 +if (Test-Path -LiteralPath $dstPluginRoot) {
  25 + Remove-Item -LiteralPath $dstPluginRoot -Recurse -Force
  26 +}
  27 +New-Item -ItemType Directory -Path (Join-Path $dstPluginRoot "android") -Force | Out-Null
  28 +Copy-Item -LiteralPath $srcPkg -Destination (Join-Path $dstPluginRoot "package.json")
  29 +Copy-Item -LiteralPath $srcReadme -Destination (Join-Path $dstPluginRoot "README.md")
  30 +Copy-Item -LiteralPath $srcAar -Destination (Join-Path $dstPluginRoot "android\native_fast_printer-release.aar")
  31 +
  32 +Write-Host "[3/4] Validate manifest plugin declaration..."
  33 +if (!(Test-Path -LiteralPath $manifestPath)) { throw "manifest not found: $manifestPath" }
  34 +$manifestContent = Get-Content -LiteralPath $manifestPath -Raw
  35 +if ($manifestContent -notmatch '"native-fast-printer"\s*:\s*\{') {
  36 + throw "manifest.json missing app-plus.nativePlugins.native-fast-printer declaration"
  37 +}
  38 +
  39 +Write-Host "[4/4] Print summary..."
  40 +$srcInfo = Get-Item -LiteralPath $srcAar
  41 +$dstAar = Join-Path $dstPluginRoot "android\native_fast_printer-release.aar"
  42 +$dstInfo = Get-Item -LiteralPath $dstAar
  43 +Write-Host "SRC AAR: $($srcInfo.Length) bytes, $($srcInfo.LastWriteTime.ToString("s"))"
  44 +Write-Host "DST AAR: $($dstInfo.Length) bytes, $($dstInfo.LastWriteTime.ToString("s"))"
  45 +Write-Host "Done: native-fast-printer synced. Next: build custom base in HBuilderX."
... ...
美国版/Food Labeling Management App UniApp/src/manifest.json
1 1 {
2 2 "name" : "food.labeling",
3   - "appid" : "__UNI__1BFD76D",
  3 + "appid" : "__UNI__5C033BE",
4 4 "description" : "",
5 5 "versionName" : "1.0.5",
6 6 "versionCode" : 105,
... ... @@ -16,6 +16,9 @@
16 16 "autoclose" : true,
17 17 "delay" : 0
18 18 },
  19 + "compatible" : {
  20 + "ignoreVersion" : true
  21 + },
19 22 /* 模块配置 */
20 23 "modules" : {
21 24 "Camera" : {},
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
... ... @@ -190,6 +190,7 @@ import { ensureBluetoothPermissions } from &#39;../../utils/print/bluetoothPermissio
190 190 import {
191 191 getNativeFastPrinterDebugInfo,
192 192 getNativeFastPrinterState,
  193 + isNativeFastPrinterAvailable,
193 194 } from '../../utils/print/nativeFastPrinter'
194 195 import {
195 196 connectBluetoothPrinter,
... ... @@ -530,20 +531,96 @@ const handleUseBuiltin = () =&gt; {
530 531 }
531 532  
532 533 const testPrinting = ref(false)
  534 +function withTimeout<T> (promise: Promise<T>, ms: number, message: string): Promise<T> {
  535 + return new Promise<T>((resolve, reject) => {
  536 + const tid = setTimeout(() => reject(new Error(message)), ms)
  537 + promise.then(
  538 + (v) => {
  539 + clearTimeout(tid)
  540 + resolve(v)
  541 + },
  542 + (e) => {
  543 + clearTimeout(tid)
  544 + reject(e)
  545 + }
  546 + )
  547 + })
  548 +}
  549 +
  550 +function buildTestPrintDebugText (): string {
  551 + const summary = getCurrentPrinterSummary()
  552 + const classicState = typeof classicBluetooth?.getDebugState === 'function'
  553 + ? classicBluetooth.getDebugState()
  554 + : null
  555 + const lines: string[] = [
  556 + `driver=${summary.driverKey || '-'}/${summary.driverName || '-'}`,
  557 + `protocol=${summary.protocol || '-'}`,
  558 + `deviceType=${summary.deviceType || '-'}`,
  559 + `transport=${(summary as any).transportMode || '-'}`,
  560 + ]
  561 + if (classicState) {
  562 + lines.push(`state=${classicState.connectionState || '-'}`)
  563 + lines.push(`connected=${String(!!classicState.socketConnected)}`)
  564 + lines.push(`outputReady=${String(!!classicState.outputReady)}`)
  565 + lines.push(`sendMode=${classicState.lastSendMode || '-'}`)
  566 + if (classicState.lastSendError || classicState.lastError) {
  567 + lines.push(`lastError=${classicState.lastSendError || classicState.lastError}`)
  568 + }
  569 + }
  570 + return lines.join('\n')
  571 +}
  572 +
533 573 /** 自检测试页,不落库接口 9(仅预览页业务打印成功后上报) */
534 574 const handleTestPrint = async () => {
535 575 if (testPrinting.value) return
  576 + const summaryPreflight = getCurrentPrinterSummary()
  577 + const classicStatePreflight = typeof classicBluetooth?.getDebugState === 'function'
  578 + ? classicBluetooth.getDebugState()
  579 + : null
  580 + const isVirtualBtPrinter = String(classicStatePreflight?.deviceName || '').toLowerCase().includes('virtual bt printer')
  581 + || String(summaryPreflight.driverName || '').toLowerCase().includes('virtual bt printer')
  582 + if (
  583 + summaryPreflight.type === 'bluetooth' &&
  584 + summaryPreflight.driverKey === 'd320fax' &&
  585 + isVirtualBtPrinter &&
  586 + !isNativeFastPrinterAvailable()
  587 + ) {
  588 + uni.showModal({
  589 + title: 'Print Not Supported In Current Build',
  590 + content:
  591 + 'Virtual BT Printer (D320FAX) requires native-fast-printer in this app build. Please use a build that includes this plugin, or switch to a non-virtual Bluetooth printer.',
  592 + showCancel: false,
  593 + })
  594 + return
  595 + }
536 596 testPrinting.value = true
  597 + let watchdog: ReturnType<typeof setTimeout> | null = null
537 598 try {
538 599 uni.showLoading({ title: 'Sending test job...', mask: true })
539   - await testPrintCurrentPrinter()
540   - await refreshNativeDebug()
  600 + watchdog = setTimeout(() => {
  601 + try { uni.hideLoading() } catch (_) {}
  602 + console.warn('[bluetooth] TEST_PRINT_TIMEOUT watchdog', buildTestPrintDebugText())
  603 + }, 35000)
  604 +
  605 + await Promise.race([
  606 + testPrintCurrentPrinter((percent) => {
  607 + if (percent > 0 && percent < 100) {
  608 + uni.showLoading({ title: `Sending ${percent}%`, mask: true })
  609 + }
  610 + }),
  611 + new Promise((_, reject) => setTimeout(() => reject(new Error('TEST_PRINT_TIMEOUT')), 25000)),
  612 + ])
  613 + await withTimeout(refreshNativeDebug(), 1500, 'DEBUG_TIMEOUT')
541 614 uni.hideLoading()
542 615 uni.showToast({ title: 'Test print sent!', icon: 'success' })
543 616 } catch (e: any) {
544   - await refreshNativeDebug()
  617 + try { await withTimeout(refreshNativeDebug(), 1200, 'DEBUG_TIMEOUT') } catch (_) {}
545 618 uni.hideLoading()
546 619 const msg = (e && e.message) ? e.message : 'Please check printer connection.'
  620 + if (msg === 'TEST_PRINT_TIMEOUT' || msg === 'DEBUG_TIMEOUT') {
  621 + console.warn('[bluetooth] test print timeout', msg, buildTestPrintDebugText())
  622 + return
  623 + }
547 624 if (msg === 'BUILTIN_PLUGIN_NOT_FOUND') {
548 625 uni.showModal({
549 626 title: 'Use Bluetooth Mode',
... ... @@ -552,13 +629,15 @@ const handleTestPrint = async () =&gt; {
552 629 success: () => { switchType('bluetooth') },
553 630 })
554 631 } else {
  632 + const debugText = buildTestPrintDebugText()
555 633 uni.showModal({
556 634 title: 'Print Failed',
557   - content: msg,
  635 + content: `${msg}\n\n${debugText}`.trim(),
558 636 showCancel: false,
559 637 })
560 638 }
561 639 } finally {
  640 + if (watchdog) clearTimeout(watchdog)
562 641 testPrinting.value = false
563 642 }
564 643 }
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -80,13 +80,47 @@
80 80 </text>
81 81 <view v-for="el in printFreeFieldList" :key="'free-' + el.id" class="print-option-block">
82 82 <text class="print-option-label">{{ freeFieldNameLabel(el) }}</text>
83   - <input
84   - class="free-field-input"
85   - type="text"
86   - :value="printFreeFieldValues[el.id] ?? ''"
87   - :placeholder="freeFieldPlaceholder(el)"
88   - @input="onFreeFieldInput(el.id, $event)"
89   - />
  83 + <template v-if="freeFieldInputKind(el) === 'text'">
  84 + <input
  85 + class="free-field-input"
  86 + type="text"
  87 + :value="printFreeFieldValues[el.id] ?? ''"
  88 + :placeholder="freeFieldPlaceholder(el)"
  89 + @input="onFreeFieldInput(el.id, $event)"
  90 + />
  91 + </template>
  92 + <template v-else-if="freeFieldInputKind(el) === 'datetime'">
  93 + <view class="datetime-picker-row">
  94 + <view
  95 + class="free-field-input picker-input"
  96 + @click="openFreeFieldPicker(el, 'date')"
  97 + >
  98 + {{ displayDateValue(printFreeFieldValues[el.id], freeFieldDateFormat(el), freeFieldPlaceholder(el)) }}
  99 + </view>
  100 + <view
  101 + class="free-field-input picker-input"
  102 + @click="openFreeFieldPicker(el, 'time')"
  103 + >
  104 + {{ displayTimeValue(printFreeFieldValues[el.id]) }}
  105 + </view>
  106 + </view>
  107 + </template>
  108 + <template v-else-if="freeFieldInputKind(el) === 'date'">
  109 + <view
  110 + class="free-field-input picker-input"
  111 + @click="openFreeFieldPicker(el, 'date')"
  112 + >
  113 + {{ displayDateValue(printFreeFieldValues[el.id], freeFieldDateFormat(el), freeFieldPlaceholder(el)) }}
  114 + </view>
  115 + </template>
  116 + <template v-else>
  117 + <view
  118 + class="free-field-input picker-input"
  119 + @click="openFreeFieldPicker(el, 'time')"
  120 + >
  121 + {{ displayTimeValue(printFreeFieldValues[el.id]) }}
  122 + </view>
  123 + </template>
90 124 </view>
91 125 </view>
92 126  
... ... @@ -169,6 +203,37 @@
169 203 <NoPrinterModal v-model="showNoPrinterModal" @connect="goBluetoothPage" />
170 204  
171 205 <SideMenu v-model="isMenuOpen" />
  206 +
  207 + <view v-if="pickerDialogVisible" class="picker-dialog-mask" @click="closeFreeFieldPicker">
  208 + <view class="picker-dialog" @click.stop>
  209 + <view class="picker-dialog-title">{{ pickerDialogTitle }}</view>
  210 + <picker-view
  211 + class="picker-view-box"
  212 + :value="pickerSelection"
  213 + @change="onPickerViewChange"
  214 + >
  215 + <picker-view-column v-if="pickerMode === 'date'">
  216 + <view v-for="y in pickerYears" :key="'y-' + y" class="picker-item">{{ y }}</view>
  217 + </picker-view-column>
  218 + <picker-view-column v-if="pickerMode === 'date'">
  219 + <view v-for="m in pickerMonths" :key="'m-' + m" class="picker-item">{{ m }}</view>
  220 + </picker-view-column>
  221 + <picker-view-column v-if="pickerMode === 'date'">
  222 + <view v-for="d in pickerDays" :key="'d-' + d" class="picker-item">{{ d }}</view>
  223 + </picker-view-column>
  224 + <picker-view-column v-if="pickerMode === 'time'">
  225 + <view v-for="h in pickerHours" :key="'h-' + h" class="picker-item">{{ h }}</view>
  226 + </picker-view-column>
  227 + <picker-view-column v-if="pickerMode === 'time'">
  228 + <view v-for="m in pickerMinutes" :key="'mm-' + m" class="picker-item">{{ m }}</view>
  229 + </picker-view-column>
  230 + </picker-view>
  231 + <view class="picker-dialog-actions">
  232 + <view class="picker-btn picker-btn-cancel" @click="closeFreeFieldPicker">Cancel</view>
  233 + <view class="picker-btn picker-btn-confirm" @click="confirmFreeFieldPicker">Confirm</view>
  234 + </view>
  235 + </view>
  236 + </view>
172 237 </view>
173 238 </template>
174 239  
... ... @@ -186,7 +251,10 @@ import {
186 251 getCurrentPrinterDriver,
187 252 printImageForCurrentPrinter,
188 253 printLabelPrintJobPayloadForCurrentPrinter,
  254 + printSystemTemplateForCurrentPrinter,
189 255 } from '../../utils/print/manager/printerManager'
  256 +import classicBluetooth from '../../utils/print/bluetoothTool.js'
  257 +import { getNativeFastPrinterState, isNativeFastPrinterAvailable } from '../../utils/print/nativeFastPrinter'
190 258 import type { SystemLabelTemplate, SystemTemplateElementBase } from '../../utils/print/types/printer'
191 259 import { fetchLabelMultipleOptionById } from '../../services/labelMultipleOption'
192 260 import {
... ... @@ -227,10 +295,17 @@ import {
227 295 renderLabelPreviewCanvasToTempPathForPrint,
228 296 renderLabelPreviewToTempPath,
229 297 } from '../../utils/labelPreview/renderLabelPreviewCanvas'
230   -import { templateHasUnsupportedNativeFastElements } from '../../utils/print/nativeTemplateElementSupport'
  298 +import {
  299 + hydrateSystemTemplateImagesForPrint,
  300 + resetHydrateImageDebugRecords,
  301 +} from '../../utils/print/hydrateTemplateImagesForPrint'
  302 +import {
  303 + normalizeTemplateForNativeFastJob,
  304 + templateHasUnsupportedNativeFastElements,
  305 +} from '../../utils/print/nativeTemplateElementSupport'
231 306 import { isTemplateWithinNativeFastPrintBounds } from '../../utils/print/templatePhysicalMm'
232 307 import { isPrinterReadySync } from '../../utils/print/printerReadiness'
233   -import { getBluetoothConnection } from '../../utils/print/printerConnection'
  308 +import { ensureNativeClassicTransportIfPossible, getBluetoothConnection } from '../../utils/print/printerConnection'
234 309 import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
235 310  
236 311 const statusBarHeight = getStatusBarHeight()
... ... @@ -244,6 +319,106 @@ const showNoPrinterModal = ref(false)
244 319  
245 320 const btConnected = ref(false)
246 321 const btDeviceName = ref('')
  322 +function buildPrintDebugText(extra?: Record<string, string | number | boolean | null | undefined>): string {
  323 + const summary = getCurrentPrinterSummary()
  324 + const conn = getBluetoothConnection()
  325 + const classicState = typeof classicBluetooth?.getDebugState === 'function'
  326 + ? classicBluetooth.getDebugState()
  327 + : null
  328 + const native = getNativeFastPrinterState() || {}
  329 + const lines: string[] = [
  330 + `mode=${summary.type || '-'}`,
  331 + `driver=${summary.driverKey || '-'}/${summary.driverName || '-'}`,
  332 + `protocol=${summary.protocol || '-'}`,
  333 + `deviceType=${summary.deviceType || '-'}`,
  334 + conn?.deviceId ? `device=${conn.deviceId}` : '',
  335 + conn?.deviceName ? `deviceName=${conn.deviceName}` : '',
  336 + (conn as any)?.transportMode ? `transport=${String((conn as any).transportMode)}` : '',
  337 + native?.available === false ? `nativeFast=${native.lastError || 'unavailable'}` : '',
  338 + ].filter(Boolean)
  339 + if (classicState) {
  340 + lines.push(`classicState=${classicState.connectionState || '-'}`)
  341 + lines.push(`connected=${String(!!classicState.socketConnected)}`)
  342 + lines.push(`outputReady=${String(!!classicState.outputReady)}`)
  343 + lines.push(`sendMode=${classicState.lastSendMode || '-'}`)
  344 + if (classicState.lastSocketStrategy) lines.push(`socket=${classicState.lastSocketStrategy}`)
  345 + if (classicState.lastSendError) lines.push(`sendError=${classicState.lastSendError}`)
  346 + if (classicState.lastSendError || classicState.lastError) {
  347 + lines.push(`lastError=${classicState.lastSendError || classicState.lastError}`)
  348 + }
  349 + }
  350 + if (extra) {
  351 + Object.keys(extra).forEach((k) => {
  352 + const v = (extra as any)[k]
  353 + if (v === undefined || v === null || v === '') return
  354 + lines.push(`${k}=${String(v)}`)
  355 + })
  356 + }
  357 + return lines.join('\n')
  358 +}
  359 +
  360 +function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
  361 + return new Promise<T>((resolve, reject) => {
  362 + const tid = setTimeout(() => reject(new Error(message)), ms)
  363 + promise.then(
  364 + (v) => { clearTimeout(tid); resolve(v) },
  365 + (e) => { clearTimeout(tid); reject(e) },
  366 + )
  367 + })
  368 +}
  369 +
  370 +/** 打印进度:全程 0–100%;节流避免经典蓝牙按块回调时 showLoading 过频卡顿(从 0 第一次前进允许 1%) */
  371 +let lastShownPrintPct = -999
  372 +function showPrintProgress (percent: number) {
  373 + const p = Math.max(0, Math.min(100, Math.round(percent)))
  374 + const firstStepFromZero = lastShownPrintPct === 0 && p > 0
  375 + if (
  376 + !firstStepFromZero &&
  377 + p !== 0 &&
  378 + p !== 100 &&
  379 + Math.abs(p - lastShownPrintPct) < 2
  380 + ) {
  381 + return
  382 + }
  383 + lastShownPrintPct = p
  384 + try {
  385 + uni.showLoading({ title: `Printing ${p}%`, mask: true })
  386 + } catch (_) {}
  387 +}
  388 +
  389 +async function ensureClassicReadyForPrint(): Promise<void> {
  390 + const summary = getCurrentPrinterSummary()
  391 + const conn = getBluetoothConnection()
  392 + if (summary.type !== 'bluetooth' || conn?.deviceType !== 'classic') return
  393 + await ensureNativeClassicTransportIfPossible()
  394 + await new Promise<void>((resolve, reject) => {
  395 + const tryConnect = typeof classicBluetooth?.connDevice === 'function'
  396 + if (!tryConnect) {
  397 + reject(new Error('CLASSIC_CONNECT_API_MISSING'))
  398 + return
  399 + }
  400 + let settled = false
  401 + const timeoutId = setTimeout(() => {
  402 + if (settled) return
  403 + settled = true
  404 + reject(new Error('CLASSIC_CONNECT_TIMEOUT'))
  405 + }, 8000)
  406 + try {
  407 + classicBluetooth.connDevice(conn.deviceId, (ok: boolean) => {
  408 + if (settled) return
  409 + settled = true
  410 + clearTimeout(timeoutId)
  411 + if (ok) resolve()
  412 + else reject(new Error(classicBluetooth.getLastError?.() || 'CLASSIC_CONNECT_FAILED'))
  413 + })
  414 + } catch (e: any) {
  415 + if (settled) return
  416 + settled = true
  417 + clearTimeout(timeoutId)
  418 + reject(e instanceof Error ? e : new Error(String(e || 'CLASSIC_CONNECT_EXCEPTION')))
  419 + }
  420 + })
  421 +}
247 422  
248 423 const labelCode = ref('')
249 424 const productId = ref('')
... ... @@ -266,6 +441,27 @@ const printOptionSelections = ref&lt;Record&lt;string, string[]&gt;&gt;({})
266 441 const dictLabelsByElementId = ref<Record<string, string>>({})
267 442 const dictValuesByElementId = ref<Record<string, string[]>>({})
268 443 const printFreeFieldValues = ref<Record<string, string>>({})
  444 +const pickerDialogVisible = ref(false)
  445 +const pickerMode = ref<'date' | 'time'>('date')
  446 +const pickerSelection = ref<number[]>([0, 0, 0])
  447 +const pickerTargetId = ref('')
  448 +const pickerTargetKind = ref<'date' | 'time' | 'datetime'>('date')
  449 +const pickerTargetFormat = ref('')
  450 +const pickerDialogTitle = computed(() => (pickerMode.value === 'date' ? 'Select date' : 'Select time'))
  451 +const pickerYears = computed(() => {
  452 + const now = new Date().getFullYear()
  453 + return Array.from({ length: 21 }, (_, i) => String(now - 10 + i))
  454 +})
  455 +const pickerMonths = computed(() => Array.from({ length: 12 }, (_, i) => String(i + 1).padStart(2, '0')))
  456 +const pickerHours = computed(() => Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0')))
  457 +const pickerMinutes = computed(() => Array.from({ length: 60 }, (_, i) => String(i).padStart(2, '0')))
  458 +const pickerDays = computed(() => {
  459 + if (pickerMode.value !== 'date') return []
  460 + const year = Number(pickerYears.value[pickerSelection.value[0]] || new Date().getFullYear())
  461 + const month = Number(pickerMonths.value[pickerSelection.value[1]] || 1)
  462 + const dayCount = new Date(year, month, 0).getDate()
  463 + return Array.from({ length: dayCount }, (_, i) => String(i + 1).padStart(2, '0'))
  464 +})
269 465  
270 466 let freeFieldPreviewTimer: ReturnType<typeof setTimeout> | null = null
271 467  
... ... @@ -324,6 +520,188 @@ function freeFieldPlaceholder(el: SystemTemplateElementBase): string {
324 520 return ''
325 521 }
326 522  
  523 +function freeFieldDateFormat(el: SystemTemplateElementBase): string {
  524 + const c = el.config || {}
  525 + return String(c.format ?? c.Format ?? '').trim()
  526 +}
  527 +
  528 +function freeFieldInputKind(el: SystemTemplateElementBase): 'text' | 'date' | 'time' | 'datetime' {
  529 + const type = String(el.type || '').toUpperCase()
  530 + const c = el.config || {}
  531 + const inputType = String(c.inputType ?? c.InputType ?? '').toLowerCase()
  532 + if (type === 'TIME') return 'time'
  533 + if (type === 'DATE' && inputType === 'datetime') return 'datetime'
  534 + if (type === 'DATE') return 'date'
  535 + return 'text'
  536 +}
  537 +
  538 +function todayYmd(): string {
  539 + const d = new Date()
  540 + const y = d.getFullYear()
  541 + const m = String(d.getMonth() + 1).padStart(2, '0')
  542 + const day = String(d.getDate()).padStart(2, '0')
  543 + return `${y}-${m}-${day}`
  544 +}
  545 +
  546 +function pickerDateValue(raw: string | undefined, format: string): string {
  547 + const v = String(raw || '').trim()
  548 + if (!v) return todayYmd()
  549 + const datePart = v.split(' ')[0]
  550 + if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) return datePart
  551 + if (/^\d{2}\/\d{2}\/\d{4}$/.test(datePart)) {
  552 + const [a, b, c] = datePart.split('/')
  553 + if (format.startsWith('DD/')) return `${c}-${b}-${a}`
  554 + return `${c}-${a}-${b}`
  555 + }
  556 + return todayYmd()
  557 +}
  558 +
  559 +function pickerTimeValue(raw: string | undefined): string {
  560 + const v = String(raw || '').trim()
  561 + const m = v.match(/(\d{2}:\d{2})/)
  562 + return m?.[1] || '12:00'
  563 +}
  564 +
  565 +function formatYmdByPattern(ymd: string, format: string): string {
  566 + const [y, m, d] = ymd.split('-')
  567 + if (!y || !m || !d) return ymd
  568 + if (format.startsWith('DD/')) return `${d}/${m}/${y}`
  569 + if (format.startsWith('MM/')) return `${m}/${d}/${y}`
  570 + return `${y}-${m}-${d}`
  571 +}
  572 +
  573 +function displayDateValue(raw: string | undefined, format: string, fallback: string): string {
  574 + const v = String(raw || '').trim()
  575 + if (v) return v.split(' ')[0]
  576 + return fallback || 'Select date'
  577 +}
  578 +
  579 +function displayTimeValue(raw: string | undefined): string {
  580 + const v = String(raw || '').trim()
  581 + const m = v.match(/(\d{2}:\d{2})/)
  582 + return m?.[1] || 'Select time'
  583 +}
  584 +
  585 +function parseExistingDateParts(raw: string): { y: string; m: string; d: string } {
  586 + const v = String(raw || '').trim()
  587 + if (!v) {
  588 + const t = todayYmd().split('-')
  589 + return { y: t[0], m: t[1], d: t[2] }
  590 + }
  591 + const datePart = v.split(' ')[0]
  592 + if (/^\d{4}-\d{2}-\d{2}$/.test(datePart)) {
  593 + const [y, m, d] = datePart.split('-')
  594 + return { y, m, d }
  595 + }
  596 + if (/^\d{2}\/\d{2}\/\d{4}$/.test(datePart)) {
  597 + const [a, b, c] = datePart.split('/')
  598 + return { y: c, m: b, d: a }
  599 + }
  600 + const t = todayYmd().split('-')
  601 + return { y: t[0], m: t[1], d: t[2] }
  602 +}
  603 +
  604 +function openFreeFieldPicker(el: SystemTemplateElementBase, mode: 'date' | 'time') {
  605 + pickerTargetId.value = el.id
  606 + pickerTargetKind.value = freeFieldInputKind(el)
  607 + pickerTargetFormat.value = freeFieldDateFormat(el)
  608 + pickerMode.value = mode
  609 + const currentRaw = String(printFreeFieldValues.value[el.id] || '').trim()
  610 + if (mode === 'date') {
  611 + const p = parseExistingDateParts(currentRaw)
  612 + const yi = Math.max(0, pickerYears.value.indexOf(p.y))
  613 + const mi = Math.max(0, pickerMonths.value.indexOf(p.m))
  614 + const dd = Array.from({ length: new Date(Number(p.y), Number(p.m), 0).getDate() }, (_, i) => String(i + 1).padStart(2, '0'))
  615 + const di = Math.max(0, dd.indexOf(p.d))
  616 + pickerSelection.value = [yi, mi, di]
  617 + } else {
  618 + const hhmm = pickerTimeValue(currentRaw).split(':')
  619 + const hi = Math.max(0, pickerHours.value.indexOf(hhmm[0] || '12'))
  620 + const mi = Math.max(0, pickerMinutes.value.indexOf(hhmm[1] || '00'))
  621 + pickerSelection.value = [hi, mi]
  622 + }
  623 + pickerDialogVisible.value = true
  624 +}
  625 +
  626 +function closeFreeFieldPicker() {
  627 + pickerDialogVisible.value = false
  628 +}
  629 +
  630 +function onPickerViewChange(e: { detail?: { value?: number[] } }) {
  631 + const v = e.detail?.value || []
  632 + pickerSelection.value = Array.isArray(v) ? v : []
  633 +}
  634 +
  635 +function confirmFreeFieldPicker() {
  636 + const id = pickerTargetId.value
  637 + if (!id) {
  638 + pickerDialogVisible.value = false
  639 + return
  640 + }
  641 + const prev = String(printFreeFieldValues.value[id] || '').trim()
  642 + if (pickerMode.value === 'date') {
  643 + const y = pickerYears.value[pickerSelection.value[0]] || String(new Date().getFullYear())
  644 + const m = pickerMonths.value[pickerSelection.value[1]] || '01'
  645 + const d = pickerDays.value[pickerSelection.value[2]] || '01'
  646 + const formatted = formatYmdByPattern(`${y}-${m}-${d}`, pickerTargetFormat.value || 'YYYY-MM-DD')
  647 + if (pickerTargetKind.value === 'datetime') {
  648 + const timePart = pickerTimeValue(prev)
  649 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [id]: `${formatted} ${timePart}` }
  650 + } else {
  651 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [id]: formatted }
  652 + }
  653 + } else {
  654 + const h = pickerHours.value[pickerSelection.value[0]] || '12'
  655 + const m = pickerMinutes.value[pickerSelection.value[1]] || '00'
  656 + const hhmm = `${h}:${m}`
  657 + if (pickerTargetKind.value === 'datetime') {
  658 + const datePart = prev.split(' ')[0] || formatYmdByPattern(todayYmd(), pickerTargetFormat.value || 'YYYY-MM-DD')
  659 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [id]: `${datePart} ${hhmm}` }
  660 + } else {
  661 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [id]: hhmm }
  662 + }
  663 + }
  664 + if (freeFieldPreviewTimer != null) clearTimeout(freeFieldPreviewTimer)
  665 + freeFieldPreviewTimer = setTimeout(() => {
  666 + freeFieldPreviewTimer = null
  667 + void refreshPreviewFromSelections()
  668 + }, 180)
  669 + pickerDialogVisible.value = false
  670 +}
  671 +
  672 +function onFreeFieldDateChange(el: SystemTemplateElementBase, e: { detail?: { value?: string } }) {
  673 + const ymd = String(e.detail?.value || '').trim()
  674 + if (!ymd) return
  675 + const id = el.id
  676 + const kind = freeFieldInputKind(el)
  677 + const formatted = formatYmdByPattern(ymd, freeFieldDateFormat(el))
  678 + const prev = String(printFreeFieldValues.value[id] || '').trim()
  679 + if (kind === 'datetime') {
  680 + const timePart = pickerTimeValue(prev)
  681 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [id]: `${formatted} ${timePart}` }
  682 + } else {
  683 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [id]: formatted }
  684 + }
  685 + if (freeFieldPreviewTimer != null) clearTimeout(freeFieldPreviewTimer)
  686 + freeFieldPreviewTimer = setTimeout(() => {
  687 + freeFieldPreviewTimer = null
  688 + void refreshPreviewFromSelections()
  689 + }, 180)
  690 +}
  691 +
  692 +function onFreeFieldTimeChange(elementId: string, e: { detail?: { value?: string } }) {
  693 + const timeVal = String(e.detail?.value || '').trim()
  694 + if (!timeVal) return
  695 + const prev = String(printFreeFieldValues.value[elementId] || '').trim()
  696 + const datePart = prev.split(' ')[0] || formatYmdByPattern(todayYmd(), 'YYYY-MM-DD')
  697 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [elementId]: `${datePart} ${timeVal}` }
  698 + if (freeFieldPreviewTimer != null) clearTimeout(freeFieldPreviewTimer)
  699 + freeFieldPreviewTimer = setTimeout(() => {
  700 + freeFieldPreviewTimer = null
  701 + void refreshPreviewFromSelections()
  702 + }, 180)
  703 +}
  704 +
327 705 function onFreeFieldInput(elementId: string, e: { detail?: { value?: string } }) {
328 706 const v = e.detail?.value ?? ''
329 707 printFreeFieldValues.value = { ...printFreeFieldValues.value, [elementId]: v }
... ... @@ -585,8 +963,56 @@ const handlePrint = async () =&gt; {
585 963 }
586 964 }
587 965  
  966 + try {
  967 + await ensureClassicReadyForPrint()
  968 + } catch (e: any) {
  969 + const msg = e?.message ? String(e.message) : 'CLASSIC_NOT_READY'
  970 + uni.showModal({
  971 + title: 'Printer Not Ready',
  972 + content: `${msg}\n\n${buildPrintDebugText({ stage: 'preflight' })}`.trim(),
  973 + showCancel: false,
  974 + })
  975 + return
  976 + }
  977 +
  978 + // Virtual BT Printer 在当前基座缺少 native-fast-printer 插件时容易出现“发送中卡死”。
  979 + // 先 fail-fast,避免进入长时间无响应流程。
  980 + const summaryPreflight = getCurrentPrinterSummary()
  981 + const connPreflight = getBluetoothConnection()
  982 + const isVirtualBtPrinter = String(connPreflight?.deviceName || '').toLowerCase().includes('virtual bt printer')
  983 + if (
  984 + summaryPreflight.type === 'bluetooth' &&
  985 + summaryPreflight.driverKey === 'd320fax' &&
  986 + isVirtualBtPrinter &&
  987 + !isNativeFastPrinterAvailable()
  988 + ) {
  989 + uni.showModal({
  990 + title: 'Print Not Supported In Current Build',
  991 + content:
  992 + 'Virtual BT Printer (D320FAX) requires native-fast-printer in this app build. Please use a build that includes this plugin, or switch to a non-virtual Bluetooth printer.',
  993 + showCancel: false,
  994 + })
  995 + return
  996 + }
  997 +
588 998 isPrinting.value = true
  999 + let watchdog: ReturnType<typeof setTimeout> | null = null
  1000 + let globalWatchdog: ReturnType<typeof setTimeout> | null = null
  1001 + const startedAt = Date.now()
  1002 + let printStage = 'start'
  1003 + let timeoutHandledByWatchdog = false
589 1004 try {
  1005 + lastShownPrintPct = -999
  1006 + globalWatchdog = setTimeout(() => {
  1007 + timeoutHandledByWatchdog = true
  1008 + try { uni.hideLoading() } catch (_) {}
  1009 + isPrinting.value = false
  1010 + console.warn(
  1011 + '[preview] PRINT_TIMEOUT_GLOBAL',
  1012 + buildPrintDebugText({ stage: printStage, elapsedMs: Date.now() - startedAt, qty: printQty.value }),
  1013 + )
  1014 + }, 70000)
  1015 +
590 1016 uni.showLoading({ title: 'Rendering…', mask: true })
591 1017 /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */
592 1018 const mergedForPrint = computeMergedPreviewTemplate()
... ... @@ -600,36 +1026,201 @@ const handlePrint = async () =&gt; {
600 1026 printOptionSelections.value,
601 1027 printFreeFieldValues.value
602 1028 )
603   - const labelPrintJobPayload = buildLabelPrintJobPayload(tmpl, printInputJson, {
604   - labelCode: labelCode.value,
605   - productId: productId.value || undefined,
606   - printQuantity: printQty.value,
607   - locationId: getCurrentStoreId() || undefined,
608   - })
609   - setLastLabelPrintJobPayload(labelPrintJobPayload)
610   -
  1029 + printStage = 'payload-ready'
611 1030 /**
612   - * 原生 printTemplate:① 物理尺寸超常见标签幅宽则回退光栅;② 含 WEIGHT/DATE/LOGO 等原生未实现类型时回退光栅(与画布一致,避免假成功)。
  1031 + * 一体机(经典蓝牙 + native-fast-printer 基座,如 Virtual BT):走原生 printLabelPrintJob。
  1032 + * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。
613 1033 */
  1034 + const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any)
614 1035 const useNativeTemplatePrint =
615 1036 canPrintCurrentLabelViaNativeFastJob()
616 1037 && isTemplateWithinNativeFastPrintBounds(tmpl)
617   - && !templateHasUnsupportedNativeFastElements(tmpl)
  1038 + && !templateHasUnsupportedNativeFastElements(tmplForNativeJob)
618 1039  
  1040 + /** 基座只认本地路径;http(s)、/picture/ 须先下载,否则 IMAGE 整块丢失 */
  1041 + let tmplForNativePayload = tmplForNativeJob
619 1042 if (useNativeTemplatePrint) {
  1043 + resetHydrateImageDebugRecords()
  1044 + tmplForNativePayload = await hydrateSystemTemplateImagesForPrint(tmplForNativeJob)
  1045 + }
  1046 + const labelPrintJobPayload = buildLabelPrintJobPayload(
  1047 + useNativeTemplatePrint ? tmplForNativePayload : tmpl,
  1048 + printInputJson,
  1049 + {
  1050 + labelCode: labelCode.value,
  1051 + productId: productId.value || undefined,
  1052 + printQuantity: printQty.value,
  1053 + locationId: getCurrentStoreId() || undefined,
  1054 + },
  1055 + )
  1056 + setLastLabelPrintJobPayload(labelPrintJobPayload)
  1057 +
  1058 + /**
  1059 + * 原生 printTemplate:物理尺寸超幅宽则回退光栅;规范化后仍有原生未覆盖元素则回退光栅。
  1060 + */
  1061 +
  1062 + if (useNativeTemplatePrint) {
  1063 + printStage = 'native-print-start'
620 1064 /** 经典蓝牙 + native-fast-printer:template + printInputJson → 原生 printTemplate(仅物理尺寸在热敏标签可打范围内) */
621 1065 uni.showLoading({ title: 'Printing…', mask: true })
622   - await printLabelPrintJobPayloadForCurrentPrinter(
  1066 + watchdog = setTimeout(() => {
  1067 + timeoutHandledByWatchdog = true
  1068 + try { uni.hideLoading() } catch (_) {}
  1069 + isPrinting.value = false
  1070 + console.warn(
  1071 + '[preview] PRINT_TIMEOUT native-fast',
  1072 + buildPrintDebugText({ branch: 'native-fast', stage: printStage, elapsedMs: Date.now() - startedAt, qty: printQty.value }),
  1073 + )
  1074 + }, 45000)
  1075 + await withTimeout(printLabelPrintJobPayloadForCurrentPrinter(
623 1076 labelPrintJobPayload,
624 1077 { printQty: printQty.value },
625 1078 (percent) => {
626   - if (percent > 5 && percent < 100) {
627   - uni.showLoading({ title: `Printing ${percent}%`, mask: true })
628   - }
  1079 + showPrintProgress(percent)
629 1080 }
630   - )
  1081 + ), 40000, 'PRINT_TIMEOUT')
  1082 + printStage = 'native-print-done'
631 1083 } else {
  1084 + printStage = 'raster-layout'
632 1085 const driver = getCurrentPrinterDriver()
  1086 + if (driver.key === 'd320fax') {
  1087 + const connNow = getBluetoothConnection()
  1088 + const isVirtualBtPrinter = String(connNow?.deviceName || '').toLowerCase().includes('virtual bt printer')
  1089 + /** Virtual 整页光栅与屏幕预览同源,可打出 ¥/价格;直发模板在部分环境位图字失败会缺底行 */
  1090 + /** 大图光栅 + 慢机可能 >8min;与原生 printCommandBytes 对齐后一般会快很多 */
  1091 + const virtualJobMs = 720000
  1092 + const nonVirtualRasterMs = 220000
  1093 + const nonVirtualDirectMs = 480000
  1094 + const globalCapMs = isVirtualBtPrinter ? 780000 : 540000
  1095 + const branchWatchMs = isVirtualBtPrinter ? 760000 : 520000
  1096 + if (globalWatchdog) {
  1097 + clearTimeout(globalWatchdog)
  1098 + globalWatchdog = null
  1099 + }
  1100 + globalWatchdog = setTimeout(() => {
  1101 + timeoutHandledByWatchdog = true
  1102 + try { uni.hideLoading() } catch (_) {}
  1103 + isPrinting.value = false
  1104 + console.warn(
  1105 + '[preview] PRINT_TIMEOUT_GLOBAL d320fax',
  1106 + buildPrintDebugText({ stage: printStage, elapsedMs: Date.now() - startedAt, qty: printQty.value }),
  1107 + )
  1108 + }, globalCapMs)
  1109 + watchdog = setTimeout(() => {
  1110 + timeoutHandledByWatchdog = true
  1111 + try { uni.hideLoading() } catch (_) {}
  1112 + isPrinting.value = false
  1113 + console.warn(
  1114 + '[preview] PRINT_TIMEOUT d320fax',
  1115 + buildPrintDebugText({ branch: 'd320fax', stage: printStage, elapsedMs: Date.now() - startedAt, qty: printQty.value }),
  1116 + )
  1117 + }, branchWatchMs)
  1118 + if (isVirtualBtPrinter) {
  1119 + showPrintProgress(0)
  1120 + try {
  1121 + printStage = 'd320fax-virtual-raster-start'
  1122 + await withTimeout(
  1123 + printSystemTemplateForCurrentPrinter(
  1124 + tmpl,
  1125 + printInputJson as any,
  1126 + {
  1127 + printQty: printQty.value,
  1128 + canvasRaster: {
  1129 + canvasId: 'labelPreviewCanvas',
  1130 + componentInstance: instance,
  1131 + applyLayout: (layout) => {
  1132 + canvasCssW.value = layout.outW
  1133 + canvasCssH.value = layout.outH
  1134 + },
  1135 + },
  1136 + },
  1137 + (percent) => showPrintProgress(percent),
  1138 + ),
  1139 + virtualJobMs,
  1140 + 'PRINT_TIMEOUT_RASTER',
  1141 + )
  1142 + printStage = 'd320fax-virtual-raster-done'
  1143 + } catch (e: any) {
  1144 + const msg = e?.message ? String(e.message) : ''
  1145 + // 超时时底层可能仍在发送,若立刻回退直发会导致“一次点击出两张”。
  1146 + if (msg.includes('PRINT_TIMEOUT')) {
  1147 + throw e
  1148 + }
  1149 + printStage = 'd320fax-virtual-direct-fallback-start'
  1150 + showPrintProgress(0)
  1151 + await withTimeout(
  1152 + printSystemTemplateForCurrentPrinter(
  1153 + tmpl,
  1154 + printInputJson as any,
  1155 + { printQty: printQty.value },
  1156 + (percent) => showPrintProgress(percent),
  1157 + ),
  1158 + virtualJobMs,
  1159 + 'PRINT_TIMEOUT_DIRECT',
  1160 + )
  1161 + printStage = 'd320fax-virtual-direct-fallback-done'
  1162 + }
  1163 + } else {
  1164 + printStage = 'd320fax-raster-start'
  1165 + showPrintProgress(0)
  1166 + try {
  1167 + await withTimeout(
  1168 + printSystemTemplateForCurrentPrinter(
  1169 + tmpl,
  1170 + printInputJson as any,
  1171 + {
  1172 + printQty: printQty.value,
  1173 + canvasRaster: {
  1174 + canvasId: 'labelPreviewCanvas',
  1175 + componentInstance: instance,
  1176 + applyLayout: (layout) => {
  1177 + canvasCssW.value = layout.outW
  1178 + canvasCssH.value = layout.outH
  1179 + },
  1180 + },
  1181 + },
  1182 + (percent) => showPrintProgress(percent),
  1183 + ),
  1184 + nonVirtualRasterMs,
  1185 + 'PRINT_TIMEOUT_RASTER',
  1186 + )
  1187 + printStage = 'd320fax-raster-done'
  1188 + } catch (e: any) {
  1189 + const msg = e?.message ? String(e.message) : ''
  1190 + // 超时时底层可能仍在发送,若立刻回退直发会导致“一次点击出两张”。
  1191 + if (msg.includes('PRINT_TIMEOUT')) {
  1192 + throw e
  1193 + }
  1194 + printStage = 'd320fax-direct-fallback-start'
  1195 + showPrintProgress(0)
  1196 + await withTimeout(
  1197 + printSystemTemplateForCurrentPrinter(
  1198 + tmpl,
  1199 + printInputJson as any,
  1200 + { printQty: printQty.value },
  1201 + (percent) => showPrintProgress(percent),
  1202 + ),
  1203 + nonVirtualDirectMs,
  1204 + 'PRINT_TIMEOUT_DIRECT_FALLBACK',
  1205 + )
  1206 + printStage = 'd320fax-direct-fallback-done'
  1207 + }
  1208 + }
  1209 + } else {
  1210 + /** 非 d320fax 整页光栅可能超过 70s,避免 PRINT_TIMEOUT_GLOBAL 先误报 */
  1211 + if (globalWatchdog) {
  1212 + clearTimeout(globalWatchdog)
  1213 + globalWatchdog = null
  1214 + }
  1215 + globalWatchdog = setTimeout(() => {
  1216 + timeoutHandledByWatchdog = true
  1217 + try { uni.hideLoading() } catch (_) {}
  1218 + isPrinting.value = false
  1219 + console.warn(
  1220 + '[preview] PRINT_TIMEOUT_GLOBAL raster',
  1221 + buildPrintDebugText({ stage: printStage, elapsedMs: Date.now() - startedAt, qty: printQty.value }),
  1222 + )
  1223 + }, 360000)
633 1224 const maxDots =
634 1225 driver.imageMaxWidthDots || (driver.protocol === 'esc' ? 384 : 576)
635 1226 const layout = getLabelPrintRasterLayout(tmpl, maxDots, driver.imageDpi || 203)
... ... @@ -645,10 +1236,30 @@ const handlePrint = async () =&gt; {
645 1236 tmpl,
646 1237 layout
647 1238 )
648   -
649   - uni.showLoading({ title: 'Printing…', mask: true })
  1239 + printStage = 'raster-image-ready'
  1240 +
  1241 + showPrintProgress(0)
  1242 + const genericRasterJobMs = 300000
  1243 + watchdog = setTimeout(() => {
  1244 + timeoutHandledByWatchdog = true
  1245 + try { uni.hideLoading() } catch (_) {}
  1246 + isPrinting.value = false
  1247 + console.warn(
  1248 + '[preview] PRINT_TIMEOUT raster',
  1249 + buildPrintDebugText({
  1250 + branch: 'raster',
  1251 + stage: printStage,
  1252 + elapsedMs: Date.now() - startedAt,
  1253 + qty: printQty.value,
  1254 + outW: layout.outW,
  1255 + outH: layout.outH,
  1256 + tmpPath: tmpPath ? 'ok' : 'empty',
  1257 + }),
  1258 + )
  1259 + }, genericRasterJobMs + 30000)
650 1260 /** 与历史验证路径一致:临时 PNG → 解码光栅 → TSC(避免预览页 canvasGetImageData 与打印机页行为不一致) */
651   - await printImageForCurrentPrinter(
  1261 + printStage = 'raster-print-start'
  1262 + await withTimeout(printImageForCurrentPrinter(
652 1263 tmpPath,
653 1264 {
654 1265 printQty: printQty.value,
... ... @@ -657,11 +1268,11 @@ const handlePrint = async () =&gt; {
657 1268 targetHeightDots: layout.outH,
658 1269 },
659 1270 (percent) => {
660   - if (percent > 5 && percent < 100) {
661   - uni.showLoading({ title: `Printing ${percent}%`, mask: true })
662   - }
  1271 + showPrintProgress(percent)
663 1272 }
664   - )
  1273 + ), genericRasterJobMs, 'PRINT_TIMEOUT')
  1274 + printStage = 'raster-print-done'
  1275 + }
665 1276 }
666 1277  
667 1278 /** 接口 9:仅本页业务标签出纸后落库(打印机设置/蓝牙 Test Print 不会执行此段) */
... ... @@ -731,7 +1342,13 @@ const handlePrint = async () =&gt; {
731 1342 })
732 1343 } catch (e: any) {
733 1344 uni.hideLoading()
  1345 + if (timeoutHandledByWatchdog) return
734 1346 const msg = e?.message ? String(e.message) : 'Print failed'
  1347 + /** withTimeout 触发的超时:不弹窗,仅日志(watchdog 已静默结束打印态) */
  1348 + if (msg.includes('PRINT_TIMEOUT')) {
  1349 + console.warn('[preview] print job timeout (promise)', msg, buildPrintDebugText({ stage: printStage, elapsedMs: Date.now() - startedAt, qty: printQty.value }))
  1350 + return
  1351 + }
735 1352 if (msg === 'BUILTIN_PLUGIN_NOT_FOUND' || (msg && msg.indexOf('Built-in printer') !== -1)) {
736 1353 uni.showModal({
737 1354 title: 'Built-in Print Not Available',
... ... @@ -744,16 +1361,27 @@ const handlePrint = async () =&gt; {
744 1361 },
745 1362 })
746 1363 } else {
747   - uni.showToast({ title: msg, icon: 'none', duration: 3000 })
  1364 + uni.showModal({
  1365 + title: 'Print Failed',
  1366 + content: `${msg}\n\n${buildPrintDebugText({ stage: printStage, elapsedMs: Date.now() - startedAt, qty: printQty.value })}`.trim(),
  1367 + showCancel: false,
  1368 + })
748 1369 }
749 1370 } finally {
  1371 + try {
  1372 + uni.hideLoading()
  1373 + } catch (_) {}
  1374 + if (watchdog) clearTimeout(watchdog)
  1375 + if (globalWatchdog) clearTimeout(globalWatchdog)
750 1376 const t = systemTemplate.value
751 1377 if (t && instance) {
752 1378 const sz = getPreviewCanvasCssSize(t, 720)
753 1379 canvasCssW.value = sz.width
754 1380 canvasCssH.value = sz.height
755 1381 }
756   - isPrinting.value = false
  1382 + if (!timeoutHandledByWatchdog) {
  1383 + isPrinting.value = false
  1384 + }
757 1385 }
758 1386 }
759 1387 </script>
... ... @@ -984,7 +1612,7 @@ const handlePrint = async () =&gt; {
984 1612 .chip-row {
985 1613 display: flex;
986 1614 flex-wrap: wrap;
987   - gap: 16rpx;
  1615 + gap: 22rpx;
988 1616 }
989 1617  
990 1618 .chip {
... ... @@ -1021,6 +1649,86 @@ const handlePrint = async () =&gt; {
1021 1649 border-radius: 12rpx;
1022 1650 }
1023 1651  
  1652 +.picker-input {
  1653 + display: flex;
  1654 + align-items: center;
  1655 +}
  1656 +
  1657 +.datetime-picker-row {
  1658 + display: flex;
  1659 + gap: 16rpx;
  1660 +}
  1661 +
  1662 +.datetime-picker-row picker {
  1663 + flex: 1;
  1664 +}
  1665 +
  1666 +.picker-dialog-mask {
  1667 + position: fixed;
  1668 + left: 0;
  1669 + right: 0;
  1670 + top: 0;
  1671 + bottom: 0;
  1672 + background: rgba(0, 0, 0, 0.45);
  1673 + z-index: 2000;
  1674 + display: flex;
  1675 + align-items: center;
  1676 + justify-content: center;
  1677 + padding: 24rpx;
  1678 +}
  1679 +
  1680 +.picker-dialog {
  1681 + width: 100%;
  1682 + max-width: 680rpx;
  1683 + background: #fff;
  1684 + border-radius: 20rpx;
  1685 + overflow: hidden;
  1686 +}
  1687 +
  1688 +.picker-dialog-title {
  1689 + font-size: 30rpx;
  1690 + font-weight: 600;
  1691 + color: #111827;
  1692 + text-align: center;
  1693 + padding: 20rpx 24rpx 8rpx;
  1694 +}
  1695 +
  1696 +.picker-view-box {
  1697 + width: 100%;
  1698 + height: 420rpx;
  1699 +}
  1700 +
  1701 +.picker-item {
  1702 + height: 72rpx;
  1703 + line-height: 72rpx;
  1704 + text-align: center;
  1705 + font-size: 30rpx;
  1706 + color: #111827;
  1707 +}
  1708 +
  1709 +.picker-dialog-actions {
  1710 + display: flex;
  1711 + border-top: 1rpx solid #e5e7eb;
  1712 +}
  1713 +
  1714 +.picker-btn {
  1715 + flex: 1;
  1716 + height: 88rpx;
  1717 + line-height: 88rpx;
  1718 + text-align: center;
  1719 + font-size: 30rpx;
  1720 +}
  1721 +
  1722 +.picker-btn-cancel {
  1723 + color: #6b7280;
  1724 + border-right: 1rpx solid #e5e7eb;
  1725 +}
  1726 +
  1727 +.picker-btn-confirm {
  1728 + color: var(--theme-primary);
  1729 + font-weight: 600;
  1730 +}
  1731 +
1024 1732 .section-title {
1025 1733 font-size: 30rpx;
1026 1734 font-weight: 600;
... ...
美国版/Food Labeling Management App UniApp/src/pages/more/print-log.vue
... ... @@ -37,6 +37,7 @@
37 37 <scroll-view
38 38 class="content"
39 39 scroll-y
  40 + :style="contentHeight > 0 ? { height: contentHeight + 'px' } : undefined"
40 41 @scrolltolower="onScrollToLower"
41 42 >
42 43 <view v-if="loading && !items.length" class="state-block">
... ... @@ -162,6 +163,7 @@ const viewMode = ref&lt;&#39;card&#39; | &#39;list&#39;&gt;(&#39;card&#39;)
162 163  
163 164 const reprintCanvasW = ref(400)
164 165 const reprintCanvasH = ref(400)
  166 +const contentHeight = ref(0)
165 167  
166 168 /** 须在 setup 顶层取实例,异步回调里 getCurrentInstance() 为 null */
167 169 const reprintCanvasComponentProxy = getCurrentInstance()?.proxy
... ... @@ -174,6 +176,22 @@ const applyReprintCanvasLayout: NonNullable&lt;SystemTemplatePrintCanvasRasterOptio
174 176 await nextTick()
175 177 }
176 178  
  179 +const computeContentHeight = async () => {
  180 + const inst = getCurrentInstance()?.proxy
  181 + if (!inst) return
  182 + await nextTick()
  183 + const windowHeight = Number(uni.getSystemInfoSync().windowHeight || 0)
  184 + const q = uni.createSelectorQuery().in(inst as any)
  185 + q.select('.header-hero').boundingClientRect()
  186 + q.select('.view-toggle').boundingClientRect()
  187 + q.exec((rects: any[]) => {
  188 + const headerH = Number(rects?.[0]?.height || 0)
  189 + const toggleH = Number(rects?.[1]?.height || 0)
  190 + const safe = Math.max(260, Math.round(windowHeight - headerH - toggleH))
  191 + contentHeight.value = safe
  192 + })
  193 +}
  194 +
177 195 const items = ref<PrintLogItemDto[]>([])
178 196 const loading = ref(false)
179 197 const loadingMore = ref(false)
... ... @@ -258,6 +276,7 @@ function onScrollToLower () {
258 276 }
259 277  
260 278 onShow(() => {
  279 + computeContentHeight()
261 280 loadPage(true)
262 281 })
263 282  
... ... @@ -273,7 +292,7 @@ const handleReprint = async (row: PrintLogItemDto) =&gt; {
273 292 }
274 293 uni.showLoading({ title: 'Rendering…', mask: true })
275 294 try {
276   - /** 整页 canvas 光栅:与 Label Preview 页「非 native 快打」同路径,图片/中文/¥ 与屏幕一致 */
  295 + /** 与预览一致:一体机/基座可走 native printTemplate,否则整页光栅;图片会先 hydration 再送基座 */
277 296 await printFromPrintLogRow(row, {
278 297 printQty: 1,
279 298 onProgress: (pct) => {
... ... @@ -375,10 +394,9 @@ const goBack = () =&gt; {
375 394 }
376 395  
377 396 .content {
378   - flex: 1;
379 397 padding: 24rpx 28rpx 40rpx;
380 398 box-sizing: border-box;
381   - height: 0;
  399 + min-height: 260px;
382 400 }
383 401  
384 402 .log-list {
... ...
美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
... ... @@ -202,6 +202,28 @@ const refreshStatus = () =&gt; {
202 202 : null
203 203 }
204 204  
  205 +function buildClassicDebugSnapshot (): string {
  206 + const summary = getCurrentPrinterSummary()
  207 + const debugState = typeof classicBluetooth?.getDebugState === 'function'
  208 + ? classicBluetooth.getDebugState()
  209 + : null
  210 + const lines: string[] = [
  211 + `driver=${summary.driverKey || '-'}/${summary.driverName || '-'}`,
  212 + `protocol=${summary.protocol || '-'}`,
  213 + `deviceType=${summary.deviceType || '-'}`,
  214 + ]
  215 + if (debugState) {
  216 + lines.push(`classicState=${debugState.connectionState || '-'}`)
  217 + lines.push(`connected=${String(!!debugState.socketConnected)}`)
  218 + lines.push(`outputReady=${String(!!debugState.outputReady)}`)
  219 + lines.push(`sendMode=${debugState.lastSendMode || '-'}`)
  220 + if (debugState.lastSendError || debugState.lastError) {
  221 + lines.push(`lastError=${debugState.lastSendError || debugState.lastError}`)
  222 + }
  223 + }
  224 + return lines.join('\n')
  225 +}
  226 +
205 227 const getTypeLabel = (type?: string) => {
206 228 switch (type) {
207 229 case 'classic': return t('printers.classic')
... ... @@ -440,7 +462,14 @@ const doTestPrint = async () =&gt; {
440 462 showCancel: false,
441 463 })
442 464 } else {
443   - uni.showToast({ title: t('printers.testPrintFail') + ': ' + msg, icon: 'none' })
  465 + /** 默认系统弹框:不设置大小,直接把调试日志写入 content,便于拍照/复制 */
  466 + const debugText = buildClassicDebugSnapshot()
  467 + uni.showModal({
  468 + title: 'Print Failed',
  469 + content: `${msg}\n\n${debugText}`.trim(),
  470 + confirmText: 'OK',
  471 + showCancel: false,
  472 + })
444 473 }
445 474 }
446 475 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
... ... @@ -108,9 +108,13 @@ function wrapTextToWidth(text: string, maxChars: number): string[] {
108 108 function previewTextForElement(element: SystemTemplateElementBase): string {
109 109 const type = String(element.type || '').toUpperCase()
110 110 const config = element.config || {}
111   - if (type === 'QRCODE' || type === 'BARCODE') {
  111 + if (type === 'QRCODE') {
112 112 return cfgStr(config, ['data', 'Data', 'value', 'Value'])
113 113 }
  114 + if (type === 'BARCODE') {
  115 + // 平台模板条码值常见键:data / barcodeData(含大小写变体)
  116 + return cfgStr(config, ['data', 'Data', 'barcodeData', 'BarcodeData', 'value', 'Value'])
  117 + }
114 118 const vst = String(element.valueSourceType || '').toUpperCase()
115 119 const inputType = String(config.inputType ?? config.InputType ?? '').toLowerCase()
116 120 const hasDict = !!(config.multipleOptionId ?? config.MultipleOptionId)
... ... @@ -164,6 +168,113 @@ function previewExportPixelRatio(): number {
164 168 }
165 169 }
166 170  
  171 +function barcodeModulesFromValue(value: string): number[] {
  172 + const s = String(value || '').trim()
  173 + if (!s) return []
  174 + const modules: number[] = []
  175 + // quiet + start(轻量预览编码:保证视觉稳定,非扫描级编码)
  176 + modules.push(1, 0, 1, 0, 1, 0, 1, 0)
  177 + for (let i = 0; i < s.length; i++) {
  178 + const code = s.charCodeAt(i) & 0xff
  179 + // 用 5bit 模块 + 分隔位,避免在窄宽度里“整块发黑”。
  180 + const key = (code ^ (i * 13) ^ (s.length * 7)) & 0x1f
  181 + for (let b = 4; b >= 0; b--) modules.push((key >> b) & 1)
  182 + modules.push(0)
  183 + }
  184 + // stop
  185 + modules.push(1, 0, 1, 1, 0, 1, 0, 1)
  186 + return modules
  187 +}
  188 +
  189 +function drawBarcodeLikePreview(
  190 + ctx: UniApp.CanvasContext,
  191 + x: number,
  192 + y: number,
  193 + w: number,
  194 + h: number,
  195 + value: string,
  196 + options?: { orientation?: string; showText?: boolean }
  197 +): void {
  198 + const bw = Math.max(40, w || 140)
  199 + const bh = Math.max(28, h || 56)
  200 + const showText = options?.showText !== false
  201 + const pad = 2
  202 + const modules = barcodeModulesFromValue(value)
  203 + const orientation = String(options?.orientation || 'horizontal').toLowerCase()
  204 + const isVertical = orientation === 'vertical'
  205 +
  206 + ctx.setFillStyle('#ffffff')
  207 + ctx.fillRect(x, y, bw, bh)
  208 + if (!modules.length) return
  209 +
  210 + const txt = String(value || '').trim()
  211 + ctx.setFillStyle('#111827')
  212 +
  213 + if (!isVertical) {
  214 + const textH = showText && txt ? Math.max(10, Math.round(bh * 0.2)) : 0
  215 + const barH = Math.max(10, bh - textH - pad * 2)
  216 + const innerW = Math.max(8, bw - pad * 2)
  217 + const moduleW = innerW / modules.length
  218 + let cursor = x + pad
  219 + for (let i = 0; i < modules.length; i++) {
  220 + if (modules[i] === 1) {
  221 + const rw = Math.max(0.7, moduleW * 0.86)
  222 + ctx.fillRect(cursor, y + pad, rw, barH)
  223 + }
  224 + cursor += moduleW
  225 + }
  226 + if (showText && txt) {
  227 + ctx.setFontSize(Math.max(9, Math.min(12, Math.round(textH * 0.8))))
  228 + ctx.setTextAlign('center')
  229 + ctx.fillText(txt, x + bw / 2, y + bh - 2)
  230 + ctx.setTextAlign('left')
  231 + }
  232 + return
  233 + }
  234 +
  235 + // vertical:条码在左,data 竖排在右
  236 + const textBandW = showText && txt ? Math.max(10, Math.round(bw * 0.18)) : 0
  237 + const barW = Math.max(10, bw - textBandW - pad * 2)
  238 + const innerH = Math.max(10, bh - pad * 2)
  239 + const moduleH = innerH / modules.length
  240 + let cursorY = y + pad
  241 + for (let i = 0; i < modules.length; i++) {
  242 + if (modules[i] === 1) {
  243 + const rh = Math.max(0.7, moduleH * 0.86)
  244 + ctx.fillRect(x + pad, cursorY, barW, rh)
  245 + }
  246 + cursorY += moduleH
  247 + }
  248 + if (showText && txt) {
  249 + const font = Math.max(9, Math.min(11, Math.floor(textBandW * 0.75)))
  250 + const cx = x + bw - textBandW / 2
  251 + const cy = y + bh / 2
  252 + const anyCtx = ctx as any
  253 + if (typeof anyCtx.save === 'function' && typeof anyCtx.rotate === 'function') {
  254 + anyCtx.save()
  255 + anyCtx.translate(cx, cy)
  256 + // 竖排文本按模板端习惯:从下到上
  257 + anyCtx.rotate(-Math.PI / 2)
  258 + ctx.setFontSize(font)
  259 + ctx.setTextAlign('center')
  260 + ctx.fillText(txt, 0, Math.min(font * 0.35, 4))
  261 + ctx.setTextAlign('left')
  262 + anyCtx.restore()
  263 + } else {
  264 + // 低端环境兜底:不旋转能力时退化为逐字竖排
  265 + let ty = y + pad + font
  266 + ctx.setFontSize(font)
  267 + ctx.setTextAlign('center')
  268 + for (let i = 0; i < txt.length; i++) {
  269 + ctx.fillText(txt[i], cx, ty)
  270 + ty += font + 1
  271 + if (ty > y + bh - 1) break
  272 + }
  273 + ctx.setTextAlign('left')
  274 + }
  275 + }
  276 +}
  277 +
167 278 /** 与屏幕预览 / 位图打印共用绘制逻辑(坐标系:设计宽 cw × ch,ctx 已 scale) */
168 279 function runLabelPreviewCanvasDraw(
169 280 canvasId: string,
... ... @@ -242,6 +353,14 @@ function runLabelPreviewCanvasDraw(
242 353 }
243 354 }
244 355  
  356 + if (type === 'BARCODE' && d) {
  357 + const orientation = cfgStr(config, ['orientation', 'Orientation'], 'horizontal')
  358 + const showText = String(config.showText ?? config.ShowText ?? 'true').toLowerCase() !== 'false'
  359 + drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText })
  360 + next()
  361 + return
  362 + }
  363 +
245 364 // 管理端可把二维码默认值存为上传图片路径,须按位图绘制而非占位符文本
246 365 if (type === 'QRCODE' && d && storedValueLooksLikeImagePath(d)) {
247 366 const src = resolveMediaUrlForApp(d)
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/bluetoothTool.d.ts 0 → 100644
  1 +declare module './bluetoothTool.js' {
  2 + const blueToothTool: any
  3 + export default blueToothTool
  4 +}
  5 +
  6 +declare module '../bluetoothTool.js' {
  7 + const blueToothTool: any
  8 + export default blueToothTool
  9 +}
  10 +
  11 +declare module '../../utils/print/bluetoothTool.js' {
  12 + const blueToothTool: any
  13 + export default blueToothTool
  14 +}
  15 +
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/bluetoothTool.js
... ... @@ -548,7 +548,11 @@ var blueToothTool = {
548 548 // #endif
549 549 return false
550 550 },
551   - sendByteData (byteData) {
  551 + /**
  552 + * @param {any} byteData
  553 + * @param {(percent0to99: number) => void} [onProgress] 按已写字节占比回调 0–99(完成由 sendByteDataAsync 再报 100)
  554 + */
  555 + sendByteData (byteData, onProgress) {
552 556 // #ifdef APP-PLUS
553 557 if (!btOutStream) {
554 558 this.state.outputReady = false
... ... @@ -567,6 +571,7 @@ var blueToothTool = {
567 571 }
568 572 try {
569 573 const CHUNK_SIZE = 4096
  574 + const total = byteData && byteData.length ? byteData.length : 0
570 575 this.state.lastSendMode = 'chunk-write'
571 576 this.state.lastSendError = ''
572 577 for (let i = 0; i < byteData.length; i += CHUNK_SIZE) {
... ... @@ -580,6 +585,15 @@ var blueToothTool = {
580 585 invoke(btOutStream, 'write', normalizeWriteByte(signedChunk[j]))
581 586 }
582 587 }
  588 + if (typeof onProgress === 'function' && total > 0) {
  589 + const sent = Math.min(total, i + chunk.length)
  590 + const pct = Math.min(99, Math.floor((sent / total) * 100))
  591 + try {
  592 + onProgress(pct)
  593 + } catch (pe) {
  594 + console.error('sendByteData onProgress failed:', pe)
  595 + }
  596 + }
583 597 }
584 598 return true
585 599 } catch (e) {
... ... @@ -592,7 +606,11 @@ var blueToothTool = {
592 606 // #endif
593 607 return false
594 608 },
595   - sendByteDataAsync (byteData, callback) {
  609 + /**
  610 + * @param {(ok: boolean, errorMessage?: string) => void} callback
  611 + * @param {(percent0to99: number) => void} [onProgress] 发送过程中 0–99
  612 + */
  613 + sendByteDataAsync (byteData, callback, onProgress) {
596 614 // #ifdef APP-PLUS
597 615 if (!btOutStream) {
598 616 this.state.outputReady = false
... ... @@ -619,7 +637,7 @@ var blueToothTool = {
619 637 let ok = false
620 638 let errorMessage = ''
621 639 try {
622   - ok = this.sendByteData(byteData)
  640 + ok = this.sendByteData(byteData, onProgress)
623 641 if (!ok) {
624 642 errorMessage = this.getLastError() || 'Classic Bluetooth send failed'
625 643 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/hydrateTemplateImagesForPrint.ts
... ... @@ -5,6 +5,51 @@
5 5 import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl'
6 6 import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer'
7 7  
  8 +type HydrateDebugStage = 'skip' | 'download-success' | 'download-failed' | 'no-need-download'
  9 +type HydrateDebugRecord = {
  10 + stage: HydrateDebugStage
  11 + payload: Record<string, unknown>
  12 +}
  13 +
  14 +const hydrateDebugRecords: HydrateDebugRecord[] = []
  15 +const MAX_HYDRATE_DEBUG_RECORDS = 40
  16 +
  17 +function logHydrateDebug (
  18 + stage: HydrateDebugStage,
  19 + payload: Record<string, unknown>,
  20 +): void {
  21 + hydrateDebugRecords.push({ stage, payload: { ...payload } })
  22 + if (hydrateDebugRecords.length > MAX_HYDRATE_DEBUG_RECORDS) {
  23 + hydrateDebugRecords.splice(0, hydrateDebugRecords.length - MAX_HYDRATE_DEBUG_RECORDS)
  24 + }
  25 + try {
  26 + console.log('[print-image-hydrate]', stage, JSON.stringify(payload))
  27 + } catch {
  28 + console.log('[print-image-hydrate]', stage, payload)
  29 + }
  30 +}
  31 +
  32 +export function resetHydrateImageDebugRecords (): void {
  33 + hydrateDebugRecords.length = 0
  34 +}
  35 +
  36 +export function getHydrateImageDebugReport (): string {
  37 + if (!hydrateDebugRecords.length) return 'No hydrate image records.'
  38 + const lines: string[] = []
  39 + hydrateDebugRecords.forEach((r, i) => {
  40 + const p = r.payload || {}
  41 + lines.push(`#${i + 1} ${r.stage}`)
  42 + if (p.elementId != null && p.elementId !== '') lines.push(`id=${String(p.elementId)}`)
  43 + if (p.type != null && p.type !== '') lines.push(`type=${String(p.type)}`)
  44 + if (p.reason != null && p.reason !== '') lines.push(`reason=${String(p.reason)}`)
  45 + if (p.raw != null && p.raw !== '') lines.push(`raw=${String(p.raw)}`)
  46 + if (p.resolvedUrl != null && p.resolvedUrl !== '') lines.push(`resolved=${String(p.resolvedUrl)}`)
  47 + if (p.tempFilePath != null && p.tempFilePath !== '') lines.push(`temp=${String(p.tempFilePath)}`)
  48 + lines.push('---')
  49 + })
  50 + return lines.join('\n')
  51 +}
  52 +
8 53 /** 与 usAppApiRequest 一致:静态图 /picture/ 常需登录态,无头下载会 401 → 解码失败、纸面空白 */
9 54 function downloadAuthHeaders (): Record<string, string> {
10 55 const h: Record<string, string> = {}
... ... @@ -29,8 +74,14 @@ function needsDownloadForDecode (raw: string): boolean {
29 74 if (!s) return false
30 75 if (s.startsWith('data:')) return false
31 76 if (s.startsWith('file://')) return false
  77 + if (s.startsWith('_doc/') || s.startsWith('_www/') || /^[A-Za-z]:[\\/]/.test(s)) return false
32 78 if (/^https?:\/\//i.test(s)) return true
33   - if (s.startsWith('/picture/') || s.startsWith('/static/')) return true
  79 + if (
  80 + s.startsWith('/picture/')
  81 + || s.startsWith('/static/')
  82 + || s.startsWith('picture/')
  83 + || s.startsWith('static/')
  84 + ) return true
34 85 return false
35 86 }
36 87  
... ... @@ -41,14 +92,28 @@ function downloadUrlToTempFile (url: string): Promise&lt;string | null&gt; {
41 92 return
42 93 }
43 94 try {
  95 + let done = false
  96 + const timer = setTimeout(() => {
  97 + if (done) return
  98 + done = true
  99 + resolve(null)
  100 + }, 6000)
44 101 uni.downloadFile({
45 102 url,
46 103 header: downloadAuthHeaders(),
47 104 success: (res) => {
  105 + if (done) return
  106 + done = true
  107 + clearTimeout(timer)
48 108 if (res.statusCode === 200 && res.tempFilePath) resolve(res.tempFilePath)
49 109 else resolve(null)
50 110 },
51   - fail: () => resolve(null),
  111 + fail: () => {
  112 + if (done) return
  113 + done = true
  114 + clearTimeout(timer)
  115 + resolve(null)
  116 + },
52 117 })
53 118 } catch {
54 119 resolve(null)
... ... @@ -56,33 +121,82 @@ function downloadUrlToTempFile (url: string): Promise&lt;string | null&gt; {
56 121 })
57 122 }
58 123  
59   -async function resolveToLocalPathIfNeeded (raw: string): Promise<string | null> {
  124 +async function resolveToLocalPathIfNeeded (
  125 + raw: string,
  126 + debugMeta: Record<string, unknown>,
  127 +): Promise<string | null> {
60 128 const trimmed = String(raw || '').trim()
61   - if (!trimmed) return null
62   - if (!needsDownloadForDecode(trimmed)) return null
  129 + if (!trimmed) {
  130 + logHydrateDebug('skip', { ...debugMeta, reason: 'empty-source' })
  131 + return null
  132 + }
  133 + if (!needsDownloadForDecode(trimmed)) {
  134 + logHydrateDebug('no-need-download', { ...debugMeta, raw: trimmed })
  135 + return null
  136 + }
63 137 const url = resolveMediaUrlForApp(trimmed)
64   - if (!url) return null
65   - return downloadUrlToTempFile(url)
  138 + if (!url) {
  139 + logHydrateDebug('download-failed', {
  140 + ...debugMeta,
  141 + raw: trimmed,
  142 + reason: 'resolveMediaUrlForApp-empty',
  143 + })
  144 + return null
  145 + }
  146 + const local = await downloadUrlToTempFile(url)
  147 + if (local) {
  148 + let finalLocal = local
  149 + // #ifdef APP-PLUS
  150 + try {
  151 + const plusAny = (globalThis as any)?.plus
  152 + const converted = plusAny?.io?.convertLocalFileSystemURL?.(local)
  153 + if (converted && typeof converted === 'string') {
  154 + finalLocal = converted
  155 + }
  156 + } catch (_) {}
  157 + // #endif
  158 + logHydrateDebug('download-success', {
  159 + ...debugMeta,
  160 + raw: trimmed,
  161 + resolvedUrl: url,
  162 + tempFilePath: finalLocal,
  163 + })
  164 + return finalLocal
  165 + }
  166 + logHydrateDebug('download-failed', {
  167 + ...debugMeta,
  168 + raw: trimmed,
  169 + resolvedUrl: url,
  170 + reason: 'downloadFile-null',
  171 + })
  172 + return null
66 173 }
67 174  
68 175 async function hydrateElement (el: SystemTemplateElementBase): Promise<SystemTemplateElementBase> {
69 176 const type = String(el.type || '').toUpperCase()
70 177 const cfg = { ...(el.config || {}) } as Record<string, unknown>
  178 + const debugMeta = {
  179 + elementId: String(el.id || ''),
  180 + type,
  181 + }
71 182  
72 183 if (type === 'IMAGE' || type === 'LOGO') {
73   - const raw = cfgStr(cfg, ['src', 'url', 'Src', 'Url'])
74   - const local = await resolveToLocalPathIfNeeded(raw)
  184 + const raw = cfgStr(cfg, ['src', 'url', 'data', 'Src', 'Url', 'Data'])
  185 + const local = await resolveToLocalPathIfNeeded(raw, debugMeta)
75 186 if (!local) return el
76 187 return {
77 188 ...el,
78   - config: { ...cfg, src: local, url: local, Src: local, Url: local },
  189 + config: { ...cfg, src: local, url: local, data: local, Src: local, Url: local, Data: local },
79 190 }
80 191 }
81 192  
82 193 if (type === 'QRCODE') {
83 194 const raw = cfgStr(cfg, ['data', 'Data'])
84   - if (!raw || !storedValueLooksLikeImagePath(raw)) return el
85   - const local = await resolveToLocalPathIfNeeded(raw)
  195 + if (!raw || !storedValueLooksLikeImagePath(raw)) {
  196 + logHydrateDebug('skip', { ...debugMeta, raw, reason: 'qrcode-data-not-image-path' })
  197 + return el
  198 + }
  199 + const local = await resolveToLocalPathIfNeeded(raw, debugMeta)
86 200 if (!local) return el
87 201 return {
88 202 ...el,
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
... ... @@ -5,10 +5,12 @@ import {
5 5 getBluetoothConnection,
6 6 getCurrentPrinterDriverKey,
7 7 getPrinterType,
  8 + isNativeBaseClassicBluetoothTransport,
8 9 sendToPrinter,
9 10 setBluetoothConnection,
10 11 setBuiltinPrinter,
11 12 } from '../printerConnection'
  13 +// @ts-ignore - js bridge module (app-plus only)
12 14 import classicBluetooth from '../bluetoothTool.js'
13 15 import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster'
14 16 import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder'
... ... @@ -50,7 +52,15 @@ function getPrinterTypeDisplayName (type: &#39;&#39; | &#39;bluetooth&#39; | &#39;builtin&#39;): string
50 52 function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDriver): Promise<void> {
51 53 return new Promise((resolve, reject) => {
52 54 // #ifdef APP-PLUS
53   - const shouldUseGenericClassicOnly = driver.key === 'gp-d320fx'
  55 + /**
  56 + * 一体机 / Virtual BT / d320fax:在已集成 native-fast-printer(安卓基座 AAR)时优先走佳博 SDK 连接;
  57 + * 不再强制仅用 JS 经典蓝牙,否则光栅大包易超时,且与「走基座」需求不符。
  58 + * gp-d320fx、ESC 等仍走通用 socket。
  59 + */
  60 + const shouldUseGenericClassicOnly =
  61 + driver.key === 'gp-d320fx' ||
  62 + driver.key === 'generic-tsc' ||
  63 + driver.protocol === 'esc'
54 64  
55 65 const connectClassicSocketFallback = () => {
56 66 try {
... ... @@ -68,7 +78,27 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive
68 78 driverKey: driver.key,
69 79 mtu: driver.preferredBleMtu || 20,
70 80 })
71   - resolve()
  81 + /**
  82 + * connDevice 回调 ok 可能早于 socket/outputStream 就绪;
  83 + * 若立刻测试打印,会出现 state=idle、outputReady=false。
  84 + */
  85 + const start = Date.now()
  86 + const poll = () => {
  87 + const st = typeof classicBluetooth.getDebugState === 'function'
  88 + ? classicBluetooth.getDebugState()
  89 + : null
  90 + const ready = !!st?.outputReady && (!!st?.socketConnected || String(st?.connectionState || '').toLowerCase() === 'connected')
  91 + if (ready) {
  92 + resolve()
  93 + return
  94 + }
  95 + if (Date.now() - start > 2500) {
  96 + reject(new Error('Classic Bluetooth connection is not ready'))
  97 + return
  98 + }
  99 + setTimeout(poll, 120)
  100 + }
  101 + poll()
72 102 return
73 103 }
74 104 const message = typeof classicBluetooth.getLastError === 'function'
... ... @@ -350,10 +380,8 @@ export function getCurrentPrinterSummary (): CurrentPrinterSummary {
350 380 }
351 381  
352 382 function canUseNativeFastTemplatePrint (driver: PrinterDriver): boolean {
353   - const connection = getBluetoothConnection()
354 383 return driver.protocol === 'tsc'
355   - && connection?.deviceType === 'classic'
356   - && connection?.transportMode === 'native-plugin'
  384 + && isNativeBaseClassicBluetoothTransport()
357 385 && isNativeFastPrinterAvailable()
358 386 }
359 387  
... ... @@ -435,6 +463,106 @@ export async function testPrintCurrentPrinter (onProgress?: (percent: number) =&gt;
435 463 return driver
436 464 }
437 465 }
  466 +
  467 + // d320fax 在部分安卓设备上经 sendToPrinter Promise 链会无回调卡住;测试页走经典蓝牙直发更稳定。
  468 + if (driver.key === 'd320fax' && connection?.deviceType === 'classic') {
  469 + // #ifdef APP-PLUS
  470 + try {
  471 + const payload = driver.buildTestPrintData().map((byte) => {
  472 + const v = byte & 0xff
  473 + return v >= 128 ? v - 256 : v
  474 + })
  475 + if (onProgress) onProgress(10)
  476 + /**
  477 + * 部分设备会出现“连接状态看似为 connected,但实际输出流不可写”的假连接。
  478 + * 测试打印前强制重连一次 classic socket,确保 btOutStream 新鲜可用。
  479 + */
  480 + await new Promise<void>((resolve, reject) => {
  481 + if (typeof classicBluetooth?.connDevice !== 'function') {
  482 + reject(new Error('D320FAX_CLASSIC_CONN_API_MISSING'))
  483 + return
  484 + }
  485 + let done = false
  486 + const timer = setTimeout(() => {
  487 + if (done) return
  488 + done = true
  489 + reject(new Error('D320FAX_CLASSIC_RECONNECT_TIMEOUT'))
  490 + }, 8000)
  491 + try {
  492 + classicBluetooth.connDevice(connection.deviceId, (ok: boolean) => {
  493 + if (done) return
  494 + done = true
  495 + clearTimeout(timer)
  496 + if (ok) resolve()
  497 + else reject(new Error(classicBluetooth.getLastError?.() || 'D320FAX_CLASSIC_RECONNECT_FAILED'))
  498 + })
  499 + } catch (e: any) {
  500 + if (done) return
  501 + done = true
  502 + clearTimeout(timer)
  503 + reject(e instanceof Error ? e : new Error(String(e || 'D320FAX_CLASSIC_RECONNECT_EXCEPTION')))
  504 + }
  505 + })
  506 + if (typeof classicBluetooth?.ensureConnection === 'function') {
  507 + classicBluetooth.ensureConnection(connection.deviceId)
  508 + }
  509 + if (onProgress) onProgress(30)
  510 + const ok = await new Promise<boolean>((resolve) => {
  511 + let done = false
  512 + const timer = setTimeout(() => {
  513 + if (done) return
  514 + done = true
  515 + resolve(false)
  516 + }, 10000)
  517 + try {
  518 + if (typeof classicBluetooth?.sendByteDataAsync === 'function') {
  519 + const started = classicBluetooth.sendByteDataAsync(payload, (sendOk: boolean) => {
  520 + if (done) return
  521 + done = true
  522 + clearTimeout(timer)
  523 + resolve(!!sendOk)
  524 + })
  525 + if (started === false) {
  526 + done = true
  527 + clearTimeout(timer)
  528 + resolve(false)
  529 + }
  530 + return
  531 + }
  532 + done = true
  533 + clearTimeout(timer)
  534 + resolve(false)
  535 + } catch {
  536 + done = true
  537 + clearTimeout(timer)
  538 + resolve(false)
  539 + }
  540 + })
  541 + if (!ok) {
  542 + const err = typeof classicBluetooth?.getLastError === 'function'
  543 + ? classicBluetooth.getLastError()
  544 + : ''
  545 + const debug = typeof classicBluetooth?.getDebugState === 'function'
  546 + ? classicBluetooth.getDebugState()
  547 + : null
  548 + const details = [
  549 + `device=${connection.deviceId}`,
  550 + `state=${String(debug?.connectionState || '-')}`,
  551 + `connected=${String(!!debug?.socketConnected)}`,
  552 + `outputReady=${String(!!debug?.outputReady)}`,
  553 + `sendMode=${String(debug?.lastSendMode || '-')}`,
  554 + err ? `lastError=${err}` : '',
  555 + ].filter(Boolean).join('\n')
  556 + throw new Error(`D320FAX_CLASSIC_SEND_TIMEOUT_OR_FAILED\n${details}`)
  557 + }
  558 + if (onProgress) onProgress(100)
  559 + return driver
  560 + } catch (e: any) {
  561 + throw (e instanceof Error ? e : new Error(String(e || 'D320FAX_CLASSIC_SEND_EXCEPTION')))
  562 + }
  563 + // #endif
  564 + }
  565 +
438 566 await sendToPrinter(driver.buildTestPrintData(), onProgress)
439 567 return driver
440 568 }
... ... @@ -454,8 +582,9 @@ export async function printImageForCurrentPrinter (
454 582 onProgress?: (percent: number) => void
455 583 ): Promise<PrinterDriver> {
456 584 const driver = getCurrentPrinterDriver()
  585 + if (onProgress) onProgress(2)
457 586 const raster = await rasterizeImageForPrinter(imageSource, driver, options)
458   - if (onProgress) onProgress(5)
  587 + if (onProgress) onProgress(18)
459 588 let data: number[] = []
460 589  
461 590 if (driver.protocol === 'esc') {
... ... @@ -464,7 +593,13 @@ export async function printImageForCurrentPrinter (
464 593 data = buildTscImageData(raster, options, driver.imageDpi || 203)
465 594 }
466 595  
467   - await sendToPrinter(data, onProgress)
  596 + if (onProgress) onProgress(22)
  597 + await sendToPrinter(data, (p) => {
  598 + if (!onProgress) return
  599 + const u = Math.min(100, Math.max(0, Math.round(p)))
  600 + onProgress(22 + Math.round((u / 100) * 78))
  601 + })
  602 + if (onProgress) onProgress(100)
468 603 return driver
469 604 }
470 605  
... ... @@ -523,19 +658,25 @@ export async function printSystemTemplateForCurrentPrinter (
523 658 const canvasRaster = options.canvasRaster
524 659  
525 660 if (canvasRaster) {
  661 + if (onProgress) onProgress(1)
526 662 const maxDots =
527 663 driver.imageMaxWidthDots || (driver.protocol === 'esc' ? 384 : 576)
528 664 const layout = getLabelPrintRasterLayout(template, maxDots, driver.imageDpi || 203)
  665 + if (onProgress) onProgress(4)
529 666 if (canvasRaster.applyLayout) {
530 667 await canvasRaster.applyLayout(layout)
531 668 }
  669 + if (onProgress) onProgress(7)
532 670 await new Promise<void>((r) => setTimeout(r, 50))
  671 + if (onProgress) onProgress(9)
533 672 const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint(
534 673 canvasRaster.canvasId,
535 674 canvasRaster.componentInstance,
536 675 template,
537 676 layout,
538 677 )
  678 + if (onProgress) onProgress(12)
  679 + /** 光栅后续 0–100 映射到整单 12–100,避免子步骤从 2% 覆盖掉前面已到 12% */
539 680 await printImageForCurrentPrinter(
540 681 tmpPath,
541 682 {
... ... @@ -544,14 +685,40 @@ export async function printSystemTemplateForCurrentPrinter (
544 685 targetWidthDots: layout.outW,
545 686 targetHeightDots: layout.outH,
546 687 },
547   - onProgress,
  688 + onProgress
  689 + ? (p) => {
  690 + onProgress(12 + Math.round((Math.min(100, Math.max(0, p)) / 100) * 88))
  691 + }
  692 + : undefined,
548 693 )
549 694 return driver
550 695 }
551 696  
552   - const templateReady = await hydrateSystemTemplateImagesForPrint(template)
553   -
554 697 const connection = getBluetoothConnection()
  698 + /**
  699 + * d320fax + classic 历史上存在卡顿风险,这里对图片 hydration 增加总超时兜底:
  700 + * - 成功:使用本地化后的图片路径,提高 IMAGE/QRCODE 出图概率
  701 + * - 超时/失败:自动回退原模板,保证仍可继续出纸
  702 + */
  703 + if (onProgress) onProgress(1)
  704 + const preferStableForD320faxClassic = driver.key === 'd320fax' && connection?.deviceType === 'classic'
  705 + let templateReady = template
  706 + if (preferStableForD320faxClassic) {
  707 + try {
  708 + if (onProgress) onProgress(3)
  709 + templateReady = await Promise.race<SystemLabelTemplate>([
  710 + hydrateSystemTemplateImagesForPrint(template),
  711 + new Promise<SystemLabelTemplate>((resolve) => setTimeout(() => resolve(template), 8000)),
  712 + ])
  713 + } catch {
  714 + templateReady = template
  715 + }
  716 + } else {
  717 + if (onProgress) onProgress(3)
  718 + templateReady = await hydrateSystemTemplateImagesForPrint(template)
  719 + }
  720 + if (onProgress) onProgress(12)
  721 +
555 722 if (
556 723 driver.protocol === 'tsc'
557 724 && connection?.deviceType === 'classic'
... ... @@ -576,15 +743,28 @@ export async function printSystemTemplateForCurrentPrinter (
576 743 }
577 744 }
578 745  
  746 + if (onProgress) onProgress(14)
579 747 const structuredTemplate = adaptSystemLabelTemplate(templateReady, data, {
580 748 dpi: driver.imageDpi || 203,
581 749 printQty: options.printQty || 1,
582   - disableBitmapText: driver.key === 'gp-d320fx',
  750 + disableBitmapText: driver.key === 'gp-d320fx' || driver.key === 'd320fax',
  751 + /**
  752 + * d320fax 内置字库对货币符号兼容差,TEXT_PRICE 容易整段丢失。
  753 + * 仅恢复“货币文本位图”兜底(非全量位图文本),兼顾稳定性与 ¥ 展示。
  754 + */
  755 + allowCurrencyBitmapWhenDisabled: true,
583 756 })
  757 + if (onProgress) onProgress(16)
584 758 const bytes = driver.protocol === 'esc'
585 759 ? buildEscPosTemplateData(structuredTemplate)
586 760 : buildTscTemplateData(structuredTemplate)
587   - await sendToPrinter(bytes, onProgress)
  761 + if (onProgress) onProgress(18)
  762 + await sendToPrinter(bytes, (p) => {
  763 + if (!onProgress) return
  764 + const u = Math.min(100, Math.max(0, Math.round(p)))
  765 + onProgress(18 + Math.round((u / 100) * 82))
  766 + })
  767 + if (onProgress) onProgress(100)
588 768 return driver
589 769 }
590 770  
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
... ... @@ -176,7 +176,11 @@ export function shouldRasterizeTextElement (text: string, type: string): boolean
176 176 const normalizedType = String(type || '').toUpperCase()
177 177 const normalizedText = normalizePrinterLikeText(text)
178 178 if (!normalizedText) return false
179   - if (normalizedType === 'TEXT_PRICE' && /[€£¥¥]/.test(normalizedText)) return true
  179 + /**
  180 + * TEXT_PRICE:d320fax 等走 TSC 内置字体时常丢整行(¥、小数点、数字组合)。
  181 + * 只要非空一律走位图,与画布一致;App 端 createTextBitmapPatch 失败时再回退 TSC。
  182 + */
  183 + if (normalizedType === 'TEXT_PRICE') return true
180 184 if (/[€£¥¥éÉáàâäãåæçèêëìíîïñòóôöõøùúûüýÿœšž]/.test(normalizedText)) return true
181 185 return /[^\x20-\x7E]/.test(normalizedText)
182 186 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts
... ... @@ -269,6 +269,49 @@ export function printNativeFastFromLabelPrintJob (options: {
269 269 })
270 270 }
271 271  
  272 +/**
  273 + * 将已生成的 TSC 等指令字节(Base64)交给原生佳博通道写出;与 connect 使用同一 GprinterBluetoothTransport。
  274 + * 需基座 AAR ≥ 1.2.0(含 printCommandBytes);旧包会走 sendToPrinter 回退到 JS 经典蓝牙。
  275 + */
  276 +export function printNativeCommandBytes (options: {
  277 + deviceId: string
  278 + deviceName?: string
  279 + base64: string
  280 +}) {
  281 + return wrapCallback('printCommandBytes', 600000, (resolve, reject) => {
  282 + try {
  283 + const nativePlugin = ensureNativePlugin()
  284 + if (typeof nativePlugin.printCommandBytes !== 'function') {
  285 + reject(new Error('NATIVE_PRINT_COMMAND_BYTES_NOT_SUPPORTED'))
  286 + return
  287 + }
  288 + nativePlugin.printCommandBytes({
  289 + deviceId: options.deviceId,
  290 + deviceName: options.deviceName || '',
  291 + base64: options.base64,
  292 + }, (raw: any) => {
  293 + const res = parsePluginResult(raw)
  294 + updateNativeState({
  295 + ...res,
  296 + lastAction: 'printCommandBytes',
  297 + })
  298 + if (res.code === 1 || res.success === true) {
  299 + resolve(res)
  300 + return
  301 + }
  302 + reject(new Error(res.msg || res.errMsg || 'NATIVE_PRINT_COMMAND_BYTES_FAILED'))
  303 + })
  304 + } catch (error: any) {
  305 + reject(error instanceof Error ? error : new Error(String(error || 'NATIVE_PRINT_COMMAND_BYTES_FAILED')))
  306 + }
  307 + })
  308 +}
  309 +
  310 +export function isNativePrintCommandBytesSupported (): boolean {
  311 + const plugin = getNativePlugin()
  312 + return !!plugin && typeof plugin.printCommandBytes === 'function'
  313 +}
  314 +
272 315 export function printNativeFastTemplate (options: {
273 316 deviceId: string
274 317 deviceName?: string
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
... ... @@ -2,16 +2,65 @@
2 2 * Android NativeTemplateCommandBuilder 仅处理:TEXT_*、QRCODE、BARCODE、IMAGE、
3 3 * 以及 border=line 的 BLANK;其余类型在原生路径下会被静默跳过(与画布预览不一致)。
4 4 */
5   -import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer'
  5 +import type {
  6 + LabelTemplateData,
  7 + SystemLabelTemplate,
  8 + SystemTemplateElementBase,
  9 +} from './types/printer'
  10 +import { applyTemplateData } from './templateRenderer'
6 11  
7 12 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean {
8 13 const type = String(el.type || '').toUpperCase()
9 14 if (type.startsWith('TEXT_')) return true
10   - if (type === 'QRCODE' || type === 'BARCODE' || type === 'IMAGE') return true
  15 + if (type === 'QRCODE' || type === 'IMAGE') return true
  16 + if (type === 'BARCODE') return true
11 17 if (type === 'BLANK') return true
12 18 return false
13 19 }
14 20  
  21 +/**
  22 + * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致),
  23 + * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。
  24 + */
  25 +export function normalizeTemplateForNativeFastJob (
  26 + template: SystemLabelTemplate,
  27 + data: LabelTemplateData
  28 +): SystemLabelTemplate {
  29 + const elements = (template.elements || []).map((el) => {
  30 + const type = String(el.type || '').toUpperCase()
  31 + const config = (el.config || {}) as Record<string, any>
  32 + if (type === 'LOGO') {
  33 + return { ...el, type: 'IMAGE' as typeof el.type }
  34 + }
  35 + if (type === 'WEIGHT' || type === 'DATE' || type === 'TIME' || type === 'DURATION') {
  36 + let text = ''
  37 + /** 与 renderLabelPreviewCanvas.previewTextForElement 一致:后端 AUTO_DB 的 DATE/TIME 常把算好的展示串写在 format 而非 text */
  38 + let raw = String(config.text ?? config.Text ?? '').trim()
  39 + if (!raw) {
  40 + raw = String(config.format ?? config.Format ?? '').trim()
  41 + }
  42 + if (!raw) {
  43 + raw = String(config.value ?? config.Value ?? '').trim()
  44 + }
  45 + if (raw) {
  46 + text = applyTemplateData(raw, data)
  47 + } else if (type === 'WEIGHT') {
  48 + const v = String(config.value ?? config.Value ?? '')
  49 + const u = String(config.unit ?? config.Unit ?? '')
  50 + if (v && u && !v.endsWith(u)) text = `${v}${u}`
  51 + else text = v || u
  52 + }
  53 + return {
  54 + ...el,
  55 + type: 'TEXT_STATIC' as typeof el.type,
  56 + config: { ...config, text },
  57 + }
  58 + }
  59 + return el
  60 + })
  61 + return { ...template, elements }
  62 +}
  63 +
15 64 /** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */
16 65 export function templateHasUnsupportedNativeFastElements (template: SystemLabelTemplate): boolean {
17 66 for (const el of template.elements || []) {
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
... ... @@ -10,6 +10,12 @@ import {
10 10 normalizeBleUuid,
11 11 } from './bleWriteModeRules'
12 12 import { getPrinterDriverByKey } from './manager/driverRegistry'
  13 +import {
  14 + connectNativeFastPrinter,
  15 + isNativeFastPrinterAvailable,
  16 + isNativePrintCommandBytesSupported,
  17 + printNativeCommandBytes,
  18 +} from './nativeFastPrinter'
13 19  
14 20 const STORAGE_PRINTER_TYPE = 'printerType'
15 21 const STORAGE_BT_DEVICE_ID = 'btDeviceId'
... ... @@ -68,7 +74,7 @@ export function setBluetoothConnection (info: {
68 74 uni.setStorageSync(STORAGE_BT_DEVICE_TYPE, info.deviceType || 'ble')
69 75 uni.setStorageSync(
70 76 STORAGE_BT_TRANSPORT_MODE,
71   - info.transportMode || (info.deviceType === 'classic' ? 'native-plugin' : 'generic')
  77 + info.transportMode || (info.deviceType === 'classic' ? 'generic' : 'generic')
72 78 )
73 79 uni.setStorageSync(STORAGE_BLE_MTU, info.mtu != null ? info.mtu : BLE_MTU_DEFAULT)
74 80 uni.setStorageSync(STORAGE_PRINTER_DRIVER_KEY, info.driverKey || '')
... ... @@ -127,6 +133,11 @@ export function getBluetoothConnection (): {
127 133 const transportMode = (uni.getStorageSync(STORAGE_BT_TRANSPORT_MODE) as 'native-plugin' | 'generic') || 'generic'
128 134 if (!deviceId) return null
129 135 if (deviceType === 'classic') {
  136 + /**
  137 + * 必须原样返回 STORAGE_BT_TRANSPORT_MODE(即上面的 transportMode)。
  138 + * 此前误把 native-plugin 读时改回 generic,导致「永远不走安卓基座 / printCommandBytes」,
  139 + * 一体机只能走 JS 经典蓝牙,极慢且易卡在 31% 等进度。
  140 + */
130 141 return {
131 142 deviceId,
132 143 deviceName: uni.getStorageSync(STORAGE_BT_DEVICE_NAME) || 'Printer',
... ... @@ -134,7 +145,7 @@ export function getBluetoothConnection (): {
134 145 characteristicId: '',
135 146 deviceType: 'classic',
136 147 transportMode,
137   - mtu: BLE_MTU_DEFAULT,
  148 + mtu: Number(uni.getStorageSync(STORAGE_BLE_MTU)) || BLE_MTU_DEFAULT,
138 149 bleWriteUsesNoResponse: false,
139 150 }
140 151 }
... ... @@ -153,6 +164,54 @@ export function getBluetoothConnection (): {
153 164 }
154 165 }
155 166  
  167 +/**
  168 + * 经典蓝牙且当前为 generic 时,在插件与 printCommandBytes 可用时尝试 connectNativeFastPrinter 并写入 native-plugin,
  169 + * 避免仍显示已连蓝牙但实际全程 JS 分包下发(极慢、易卡进度)。
  170 + */
  171 +export async function ensureNativeClassicTransportIfPossible (): Promise<boolean> {
  172 + const conn = getBluetoothConnection()
  173 + if (!conn || conn.deviceType !== 'classic') return false
  174 + if (conn.transportMode === 'native-plugin') return true
  175 + if (!isNativeFastPrinterAvailable() || !isNativePrintCommandBytesSupported()) return false
  176 + const driverKey = getCurrentPrinterDriverKey()
  177 + const name = String(conn.deviceName || '').toLowerCase()
  178 + const preferNative =
  179 + driverKey === 'd320fax' ||
  180 + name.includes('virtual bt') ||
  181 + name.includes('gprinter') ||
  182 + name.includes('d320')
  183 + if (!preferNative) return false
  184 + try {
  185 + await connectNativeFastPrinter({
  186 + deviceId: conn.deviceId,
  187 + deviceName: conn.deviceName || '',
  188 + })
  189 + setBluetoothConnection({
  190 + deviceId: conn.deviceId,
  191 + deviceName: conn.deviceName,
  192 + serviceId: conn.serviceId,
  193 + characteristicId: conn.characteristicId,
  194 + deviceType: 'classic',
  195 + transportMode: 'native-plugin',
  196 + mtu: conn.mtu,
  197 + driverKey: getCurrentPrinterDriverKey(),
  198 + })
  199 + return true
  200 + } catch (e) {
  201 + console.warn('[printer] ensureNativeClassicTransportIfPossible failed', e)
  202 + return false
  203 + }
  204 +}
  205 +
  206 +/**
  207 + * 经典蓝牙已走 native-fast-printer 基座链路(常见:一体机「Virtual BT Printer」/ 佳博 SDK)。
  208 + * 预览与打印日志重打在此模式下应走原生 printTemplate;否则走 BLE 或 JS 经典蓝牙光栅/直发 TSC。
  209 + */
  210 +export function isNativeBaseClassicBluetoothTransport (): boolean {
  211 + const conn = getBluetoothConnection()
  212 + return conn?.deviceType === 'classic' && conn?.transportMode === 'native-plugin'
  213 +}
  214 +
156 215 export function isBuiltinConnected (): boolean {
157 216 return getPrinterType() === 'builtin'
158 217 }
... ... @@ -222,6 +281,12 @@ export function sendToPrinter (
222 281 if (type === 'bluetooth') {
223 282 const conn = getBluetoothConnection()
224 283 if (conn && conn.deviceType === 'classic') {
  284 + if (conn.transportMode === 'native-plugin' && isNativePrintCommandBytesSupported()) {
  285 + return sendViaNativeClassicPlugin(data, onProgress).catch((err) => {
  286 + console.warn('[printer] native printCommandBytes failed, fallback JS classic', err)
  287 + return sendViaClassic(data, onProgress)
  288 + })
  289 + }
225 290 return sendViaClassic(data, onProgress)
226 291 }
227 292 return sendViaBle(data, onProgress)
... ... @@ -605,6 +670,57 @@ function sendViaBle (
605 670 return runWritesWithPayloadSize(mtuToPayloadSize(conn.mtu || BLE_MTU_DEFAULT))
606 671 }
607 672  
  673 +/** 大包/虚拟蓝牙写入慢:按字节量拉长等待,避免 JS 已超时拒绝但底层仍在写出(纸已出、接口 9 未落库) */
  674 +export function estimateClassicSendTimeoutMs (byteLength: number): number {
  675 + const n = Math.max(0, Math.floor(byteLength || 0))
  676 + const base = 90000
  677 + const perByte = Math.floor(n / 400)
  678 + return Math.min(600000, Math.max(60000, base + perByte))
  679 +}
  680 +
  681 +function numberArrayToBase64 (data: number[]): string {
  682 + const u8 = new Uint8Array(data.length)
  683 + for (let i = 0; i < data.length; i++) u8[i] = data[i] & 0xff
  684 + try {
  685 + const u = uni as any
  686 + if (typeof u.arrayBufferToBase64 === 'function') {
  687 + return u.arrayBufferToBase64(u8.buffer)
  688 + }
  689 + } catch (_) {}
  690 + let binary = ''
  691 + for (let i = 0; i < u8.length; i++) binary += String.fromCharCode(u8[i])
  692 + if (typeof btoa !== 'undefined') return btoa(binary)
  693 + return ''
  694 +}
  695 +
  696 +/**
  697 + * 经典蓝牙已走 native-fast-printer 佳博 SDK 时,整页光栅字节经 printCommandBytes 下发,避免 JS 蓝牙慢发。
  698 + */
  699 +function sendViaNativeClassicPlugin (
  700 + data: number[],
  701 + onProgress?: (percent: number) => void
  702 +): Promise<void> {
  703 + const conn = getBluetoothConnection()
  704 + if (!conn || conn.deviceType !== 'classic' || conn.transportMode !== 'native-plugin') {
  705 + return Promise.reject(new Error('NATIVE_CLASSIC_TRANSPORT_NOT_ACTIVE'))
  706 + }
  707 + if (!isNativePrintCommandBytesSupported()) {
  708 + return Promise.reject(new Error('NATIVE_PRINT_COMMAND_BYTES_NOT_SUPPORTED'))
  709 + }
  710 + const base64 = numberArrayToBase64(data)
  711 + if (!base64) {
  712 + return Promise.reject(new Error('BASE64_ENCODE_FAILED'))
  713 + }
  714 + if (onProgress) onProgress(5)
  715 + return printNativeCommandBytes({
  716 + deviceId: conn.deviceId,
  717 + deviceName: conn.deviceName,
  718 + base64,
  719 + }).then(() => {
  720 + if (onProgress) onProgress(100)
  721 + })
  722 +}
  723 +
608 724 function sendViaClassic (
609 725 data: number[],
610 726 onProgress?: (percent: number) => void
... ... @@ -614,6 +730,11 @@ function sendViaClassic (
614 730 if (!conn || conn.deviceType !== 'classic') {
615 731 return Promise.reject(new Error('Classic Bluetooth printer not connected.'))
616 732 }
  733 + const sendData = data.map((byte) => {
  734 + const value = byte & 0xff
  735 + return value >= 128 ? value - 256 : value
  736 + })
  737 + const sendTimeoutMs = estimateClassicSendTimeoutMs(sendData.length)
617 738 return new Promise((resolve, reject) => {
618 739 let settled = false
619 740 const finish = (fn: () => void) => {
... ... @@ -626,62 +747,93 @@ function sendViaClassic (
626 747 finish(() => {
627 748 reject(buildClassicBluetoothError('Classic Bluetooth send timeout', conn.deviceId))
628 749 })
629   - }, 15000)
  750 + }, sendTimeoutMs + 25000)
630 751  
631 752 try {
632 753 if (!classicBluetooth) {
633 754 finish(() => reject(new Error('Classic Bluetooth not available')))
634 755 return
635 756 }
636   - const debugState = typeof classicBluetooth.getDebugState === 'function'
637   - ? classicBluetooth.getDebugState()
638   - : null
639   - const connectionState = String(debugState?.connectionState || '').trim().toLowerCase()
640   - const ready = debugState
641   - ? (!!debugState.outputReady && (!!debugState.socketConnected || connectionState === 'connected'))
642   - : true
643   - if (!ready) {
644   - const errorMessage = typeof classicBluetooth.getLastError === 'function'
645   - ? classicBluetooth.getLastError()
646   - : ''
647   - finish(() => reject(buildClassicBluetoothError(errorMessage || 'Classic Bluetooth connection is not ready', conn.deviceId)))
648   - return
  757 + const isReady = () => {
  758 + const debugState = typeof classicBluetooth.getDebugState === 'function'
  759 + ? classicBluetooth.getDebugState()
  760 + : null
  761 + const connectionState = String(debugState?.connectionState || '').trim().toLowerCase()
  762 + const ready = debugState
  763 + ? (!!debugState.outputReady && (!!debugState.socketConnected || connectionState === 'connected'))
  764 + : true
  765 + return { ready, debugState }
649 766 }
650 767  
651   - const sendData = data.map((byte) => {
652   - const value = byte & 0xff
653   - return value >= 128 ? value - 256 : value
654   - })
655   -
656   - if (typeof classicBluetooth.sendByteDataAsync === 'function') {
657   - classicBluetooth.sendByteDataAsync(sendData, (ok: boolean, errorMessage?: string) => {
658   - finish(() => {
659   - if (onProgress) onProgress(100)
660   - if (ok) {
661   - resolve()
662   - return
663   - }
664   - reject(buildClassicBluetoothError(
665   - errorMessage || classicBluetooth.getLastError?.() || 'Classic Bluetooth send failed',
666   - conn.deviceId
667   - ))
668   - })
669   - })
670   - return
  768 + const sendNow = () => {
  769 + if (typeof classicBluetooth.sendByteDataAsync === 'function') {
  770 + let callbackSettled = false
  771 + const asyncTimeoutTimer = setTimeout(() => {
  772 + if (callbackSettled) return
  773 + callbackSettled = true
  774 + finish(() => reject(buildClassicBluetoothError('Classic Bluetooth async send timeout', conn.deviceId)))
  775 + }, sendTimeoutMs)
  776 + const started = classicBluetooth.sendByteDataAsync(
  777 + sendData,
  778 + (ok: boolean, errorMessage?: string) => {
  779 + callbackSettled = true
  780 + clearTimeout(asyncTimeoutTimer)
  781 + finish(() => {
  782 + if (onProgress) onProgress(100)
  783 + if (ok) {
  784 + resolve()
  785 + return
  786 + }
  787 + reject(buildClassicBluetoothError(
  788 + errorMessage || classicBluetooth.getLastError?.() || 'Classic Bluetooth send failed',
  789 + conn.deviceId
  790 + ))
  791 + })
  792 + },
  793 + (chunkPct: number) => {
  794 + if (onProgress && typeof chunkPct === 'number') {
  795 + try {
  796 + onProgress(Math.max(0, Math.min(99, Math.floor(chunkPct))))
  797 + } catch (_) {}
  798 + }
  799 + },
  800 + )
  801 + if (started === false) {
  802 + clearTimeout(asyncTimeoutTimer)
  803 + finish(() => reject(buildClassicBluetoothError('Classic Bluetooth async send start failed', conn.deviceId)))
  804 + }
  805 + return
  806 + }
  807 + finish(() => reject(buildClassicBluetoothError('Classic Bluetooth async API missing', conn.deviceId)))
671 808 }
672 809  
673   - const ok = classicBluetooth.sendByteData(sendData)
674   - finish(() => {
675   - if (onProgress) onProgress(100)
676   - if (ok) {
677   - resolve()
  810 + const waitReadyAndSend = (startMs: number) => {
  811 + const { ready } = isReady()
  812 + if (ready) {
  813 + sendNow()
678 814 return
679 815 }
680   - const errorMessage = typeof classicBluetooth.getLastError === 'function'
681   - ? classicBluetooth.getLastError()
682   - : ''
683   - reject(buildClassicBluetoothError(errorMessage || 'Classic Bluetooth send failed', conn.deviceId))
684   - })
  816 + if (Date.now() - startMs > 1500) {
  817 + const errorMessage = typeof classicBluetooth.getLastError === 'function'
  818 + ? classicBluetooth.getLastError()
  819 + : ''
  820 + finish(() => reject(buildClassicBluetoothError(errorMessage || 'Classic Bluetooth connection is not ready', conn.deviceId)))
  821 + return
  822 + }
  823 + setTimeout(() => waitReadyAndSend(startMs), 120)
  824 + }
  825 +
  826 + /**
  827 + * 新型号/部分安卓机:连接建立慢,或页面切换后 socket 丢失但 UI 仍显示已连。
  828 + * 发送前补连一次,再等短时间就绪。
  829 + */
  830 + try {
  831 + const { ready } = isReady()
  832 + if (!ready && typeof classicBluetooth.ensureConnection === 'function') {
  833 + classicBluetooth.ensureConnection(conn.deviceId)
  834 + }
  835 + } catch (_) {}
  836 + waitReadyAndSend(Date.now())
685 837 } catch (e: any) {
686 838 finish(() => reject(buildClassicBluetoothError(e?.message || String(e || 'Classic Bluetooth send exception'), conn.deviceId)))
687 839 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
... ... @@ -129,9 +129,9 @@ function formatPriceValue (
129 129 rawValue: string,
130 130 config: Record<string, any>
131 131 ): string {
132   - const prefix = getConfigString(config, ['prefix'], '')
133   - const suffix = getConfigString(config, ['suffix'], '')
134   - const decimal = getConfigNumber(config, ['decimal'], -1)
  132 + const prefix = getConfigString(config, ['prefix', 'Prefix'], '')
  133 + const suffix = getConfigString(config, ['suffix', 'Suffix'], '')
  134 + const decimal = getConfigNumber(config, ['decimal', 'Decimal'], -1)
135 135 const numericValue = Number(rawValue)
136 136 const value = !Number.isNaN(numericValue) && Number.isFinite(numericValue) && decimal >= 0
137 137 ? numericValue.toFixed(decimal)
... ... @@ -174,13 +174,24 @@ function resolveElementText (
174 174 if (type === 'TEXT_PRICE') {
175 175 const bindingKey = resolveBindingKey(element)
176 176 const boundValue = resolveTemplateFieldValue(data, bindingKey)
177   - const rawCfg = getConfigString(config, ['text', 'Text'])
  177 + const rawCfg = getConfigString(config, [
  178 + 'text',
  179 + 'Text',
  180 + 'value',
  181 + 'Value',
  182 + 'displayText',
  183 + 'DisplayText',
  184 + 'displayValue',
  185 + 'DisplayValue',
  186 + 'defaultValue',
  187 + 'DefaultValue',
  188 + ])
178 189 /** FIXED:重打快照里价格已在 config.text,勿用空 data 绑定出 0 */
179 190 if (vst === 'FIXED' && rawCfg.trim()) {
180 191 return formatPriceValue(rawCfg, config)
181 192 }
182   - const baseValue = boundValue || (hasText ? applyTemplateData(String(config.text), data) : '')
183   - return baseValue ? formatPriceValue(baseValue, config) : ''
  193 + const baseValue = boundValue || (rawCfg ? applyTemplateData(rawCfg, data) : '')
  194 + return baseValue.trim() ? formatPriceValue(baseValue, config) : ''
184 195 }
185 196  
186 197 /** FIXED:TEXT_PRODUCT 等勿在 data 为空时仍走 productName 绑定(与快照 config.text 冲突) */
... ... @@ -321,6 +332,30 @@ function sanitizeTextForTscBuiltinFont (text: string): string {
321 332 .replace(/¥/g, '\u00A5')
322 333 }
323 334  
  335 +/** 估算 TSC 项底部点坐标,用于避免 SIZE 高度略小于内容时裁掉最后一行(常见于底部价格) */
  336 +function estimateTscItemBottomDots (item: TscTemplateItem): number {
  337 + switch (item.type) {
  338 + case 'bitmap':
  339 + return item.y + item.image.height
  340 + case 'text': {
  341 + const scale = item.yScale || 1
  342 + return item.y + Math.round(24 * scale * 2)
  343 + }
  344 + case 'qrcode': {
  345 + const cw = item.cellWidth || 4
  346 + return item.y + cw * 72
  347 + }
  348 + case 'barcode':
  349 + return item.y + Math.max(20, Math.round(item.height || 80))
  350 + case 'bar':
  351 + return item.y + item.height
  352 + case 'box':
  353 + return item.y + item.height
  354 + default:
  355 + return 0
  356 + }
  357 +}
  358 +
324 359 function buildTscTemplate (
325 360 template: SystemLabelTemplate,
326 361 data: LabelTemplateData,
... ... @@ -328,10 +363,11 @@ function buildTscTemplate (
328 363 printQty: number,
329 364 options: {
330 365 disableBitmapText?: boolean
  366 + allowCurrencyBitmapWhenDisabled?: boolean
331 367 } = {}
332 368 ): StructuredTscTemplate {
333 369 const widthMm = roundNumber(toMillimeter(template.width, template.unit || 'inch'))
334   - const heightMm = roundNumber(toMillimeter(template.height, template.unit || 'inch'))
  370 + let heightMm = roundNumber(toMillimeter(template.height, template.unit || 'inch'))
335 371 const items: TscTemplateItem[] = []
336 372  
337 373 const pageWidth = templateWidthPx(template)
... ... @@ -356,13 +392,16 @@ function buildTscTemplate (
356 392 const align = resolveElementAlign(element, pageWidth)
357 393  
358 394 /**
359   - * gp-d320fx 等机型默认 disableBitmapText(走 TSC 文本);但内置字库把 ¥(0xA5) 打成字母 Y。
360   - * 含货币符号时仍尝试 Android 位图文本,成功则纸面与预览一致。
  395 + * gp-d320fx / d320fax 等 disableBitmapText 时:内置字库对 ¥ 与 TEXT_PRICE 整行常异常。
  396 + * 在 allowCurrencyBitmapWhenDisabled 下对 TEXT_PRICE 一律尝试位图(不仅限于含货币符),与预览一致。
361 397 */
362 398 const currencyGlyph = /[\u00A5\uFFE5€£¥]/.test(text)
  399 + const allowBitmapWhenDisabled =
  400 + options.allowCurrencyBitmapWhenDisabled !== false &&
  401 + (currencyGlyph || type === 'TEXT_PRICE')
363 402 const tryTextBitmap =
364 403 shouldRasterizeTextElement(text, type) &&
365   - (!options.disableBitmapText || currencyGlyph)
  404 + (!options.disableBitmapText || allowBitmapWhenDisabled)
366 405 if (tryTextBitmap) {
367 406 const bitmapPatch = createTextBitmapPatch({
368 407 element,
... ... @@ -444,10 +483,22 @@ function buildTscTemplate (
444 483 }
445 484  
446 485 if (type === 'IMAGE') {
447   - const bitmapPatch = createImageBitmapPatch({
  486 + let bitmapPatch = createImageBitmapPatch({
448 487 element,
449 488 dpi,
450 489 })
  490 + if (!bitmapPatch) {
  491 + const boundImage = resolveElementDataValue(element, data)
  492 + if (storedValueLooksLikeImagePath(boundImage)) {
  493 + bitmapPatch = createImageBitmapPatch({
  494 + element: {
  495 + ...element,
  496 + config: { ...config, src: boundImage, url: boundImage, Src: boundImage, Url: boundImage },
  497 + },
  498 + dpi,
  499 + })
  500 + }
  501 + }
451 502 if (bitmapPatch) items.push(bitmapPatch)
452 503 return
453 504 }
... ... @@ -463,6 +514,15 @@ function buildTscTemplate (
463 514 }
464 515 })
465 516  
  517 + const maxBottomDots = items.reduce(
  518 + (acc, it) => Math.max(acc, estimateTscItemBottomDots(it)),
  519 + 0
  520 + )
  521 + const templateHeightDots = Math.max(1, Math.round((heightMm / 25.4) * dpi))
  522 + if (maxBottomDots > templateHeightDots - 2) {
  523 + heightMm = roundNumber(Math.max(heightMm, (maxBottomDots / dpi) * 25.4 + 2))
  524 + }
  525 +
466 526 return {
467 527 widthMm,
468 528 heightMm,
... ... @@ -563,6 +623,7 @@ export function adaptSystemLabelTemplate (
563 623 dpi?: number
564 624 printQty?: number
565 625 disableBitmapText?: boolean
  626 + allowCurrencyBitmapWhenDisabled?: boolean
566 627 } = {}
567 628 ): StructuredLabelTemplate {
568 629 const dpi = options.dpi || 203
... ... @@ -571,6 +632,7 @@ export function adaptSystemLabelTemplate (
571 632 key: template.id || template.name || 'system-label-template',
572 633 tsc: buildTscTemplate(template, data, dpi, printQty, {
573 634 disableBitmapText: options.disableBitmapText,
  635 + allowCurrencyBitmapWhenDisabled: options.allowCurrencyBitmapWhenDisabled,
574 636 }),
575 637 esc: buildEscTemplate(template, data, printQty),
576 638 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts
... ... @@ -4,9 +4,28 @@ import {
4 4 sortElementsForPreview,
5 5 } from './labelPreview/normalizePreviewTemplate'
6 6 import {
  7 + canPrintCurrentLabelViaNativeFastJob,
  8 + printLabelPrintJobPayloadForCurrentPrinter,
7 9 printSystemTemplateForCurrentPrinter,
8 10 type SystemTemplatePrintCanvasRasterOptions,
9 11 } from './print/manager/printerManager'
  12 +import {
  13 + buildLabelPrintJobPayload,
  14 + setLastLabelPrintJobPayload,
  15 +} from './labelPreview/buildLabelPrintPayload'
  16 +import { getCurrentStoreId } from './stores'
  17 +import {
  18 + ensureNativeClassicTransportIfPossible,
  19 +} from './print/printerConnection'
  20 +import {
  21 + hydrateSystemTemplateImagesForPrint,
  22 + resetHydrateImageDebugRecords,
  23 +} from './print/hydrateTemplateImagesForPrint'
  24 +import {
  25 + normalizeTemplateForNativeFastJob,
  26 + templateHasUnsupportedNativeFastElements,
  27 +} from './print/nativeTemplateElementSupport'
  28 +import { isTemplateWithinNativeFastPrintBounds } from './print/templatePhysicalMm'
10 29 import type {
11 30 LabelTemplateData,
12 31 SystemLabelTemplate,
... ... @@ -327,6 +346,52 @@ export type PrintFromPrintLogOptions = {
327 346 }
328 347  
329 348 /**
  349 + * 与 Label Preview 一致:一体机(经典蓝牙 + native-plugin 基座)且模板可走原生时走 printLabelPrintJob + 本地图片 hydration;
  350 + * 普通蓝牙仍走 canvas 光栅或直发 TSC。
  351 + */
  352 +async function printReprintTemplateWithPreviewStrategy (
  353 + tmpl: SystemLabelTemplate,
  354 + row: PrintLogItemDto,
  355 + options: PrintFromPrintLogOptions,
  356 +): Promise<void> {
  357 + await ensureNativeClassicTransportIfPossible()
  358 + const templateData = labelTemplateDataForSnapshotReprint()
  359 + const printInputJson: Record<string, unknown> = {}
  360 + const tmplForNative = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any)
  361 + const useNative =
  362 + canPrintCurrentLabelViaNativeFastJob()
  363 + && isTemplateWithinNativeFastPrintBounds(tmpl)
  364 + && !templateHasUnsupportedNativeFastElements(tmplForNative)
  365 +
  366 + const printQty = options.printQty ?? 1
  367 +
  368 + if (useNative) {
  369 + resetHydrateImageDebugRecords()
  370 + const hydrated = await hydrateSystemTemplateImagesForPrint(tmplForNative)
  371 + const payload = buildLabelPrintJobPayload(hydrated, printInputJson, {
  372 + labelCode: row.labelCode,
  373 + productId: row.productId ?? undefined,
  374 + printQuantity: printQty,
  375 + locationId: getCurrentStoreId() || undefined,
  376 + })
  377 + setLastLabelPrintJobPayload(payload)
  378 + await printLabelPrintJobPayloadForCurrentPrinter(
  379 + payload,
  380 + { printQty },
  381 + options.onProgress,
  382 + )
  383 + return
  384 + }
  385 +
  386 + await printSystemTemplateForCurrentPrinter(
  387 + tmpl,
  388 + templateData,
  389 + { printQty, canvasRaster: options.canvasRaster },
  390 + options.onProgress,
  391 + )
  392 +}
  393 +
  394 +/**
330 395 * 使用接口 10 返回的 `printDataList` 组装模板并走当前打印机。
331 396 */
332 397 function logReprintJson (label: string, data: unknown): void {
... ... @@ -382,12 +447,7 @@ export async function printFromPrintDataListRow (
382 447  
383 448 logReprintJson('bake + sort 后送打印机模板 tmpl', tmpl)
384 449  
385   - await printSystemTemplateForCurrentPrinter(
386   - tmpl,
387   - templateData,
388   - { printQty: options.printQty ?? 1, canvasRaster: options.canvasRaster },
389   - options.onProgress
390   - )
  450 + await printReprintTemplateWithPreviewStrategy(tmpl, row, options)
391 451 }
392 452  
393 453 /**
... ... @@ -519,10 +579,5 @@ export async function printFromMergedTemplateJsonString (
519 579 logReprintJson('bake + sort 后送打印机模板 tmpl', tmpl)
520 580 logReprintJson('templateData(快照重打为空对象)', templateData)
521 581  
522   - await printSystemTemplateForCurrentPrinter(
523   - tmpl,
524   - templateData,
525   - { printQty: options.printQty ?? 1, canvasRaster: options.canvasRaster },
526   - options.onProgress
527   - )
  582 + await printReprintTemplateWithPreviewStrategy(tmpl, row, options)
528 583 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelGetListOutputDto.cs
... ... @@ -19,6 +19,11 @@ public class LabelGetListOutputDto
19 19  
20 20 public string TemplateName { get; set; } = string.Empty;
21 21  
  22 + /// <summary>
  23 + /// 绑定模板的编码(与详情接口一致,供管理端「录入数据」等按模板操作)
  24 + /// </summary>
  25 + public string TemplateCode { get; set; } = string.Empty;
  26 +
22 27 public string LabelTypeName { get; set; } = string.Empty;
23 28  
24 29 public bool State { get; set; }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelTemplate/LabelTemplateElementDto.cs
... ... @@ -13,6 +13,12 @@ public class LabelTemplateElementDto
13 13 [JsonPropertyName("type")]
14 14 public string ElementType { get; set; } = string.Empty;
15 15  
  16 + /// <summary>
  17 + /// 元素附加类型(分组前缀 + 控件名),如 label_Duration
  18 + /// </summary>
  19 + [JsonPropertyName("typeAdd")]
  20 + public string? TypeAdd { get; set; }
  21 +
16 22 [JsonPropertyName("elementName")]
17 23 public string ElementName { get; set; } = string.Empty;
18 24  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/DbModels/FlLabelTemplateElementDbEntity.cs
... ... @@ -14,6 +14,9 @@ public class FlLabelTemplateElementDbEntity
14 14  
15 15 public string ElementType { get; set; } = string.Empty;
16 16  
  17 + [SugarColumn(ColumnName = "TypeAdd")]
  18 + public string? TypeAdd { get; set; }
  19 +
17 20 [SugarColumn(ColumnName = "ElementName")]
18 21 public string ElementName { get; set; } = string.Empty;
19 22  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelAppService.cs
... ... @@ -127,6 +127,7 @@ public class LabelAppService : ApplicationService, ILabelAppService
127 127 LabelCategoryName = c.CategoryName,
128 128 LabelTypeName = t.TypeName,
129 129 TemplateName = tpl.TemplateName,
  130 + TemplateCode = tpl.TemplateCode,
130 131 l.State,
131 132 LastEdited = l.LastModificationTime ?? l.CreationTime
132 133 })
... ... @@ -196,6 +197,7 @@ public class LabelAppService : ApplicationService, ILabelAppService
196 197 ProductCategoryName = string.IsNullOrWhiteSpace(productCategoryNameValue) ? "无" : productCategoryNameValue,
197 198 Products = products,
198 199 TemplateName = x.TemplateName ?? string.Empty,
  200 + TemplateCode = x.TemplateCode ?? string.Empty,
199 201 LabelTypeName = x.LabelTypeName ?? string.Empty,
200 202 State = x.State,
201 203 LastEdited = x.LastEdited,
... ... @@ -695,6 +697,7 @@ public class LabelAppService : ApplicationService, ILabelAppService
695 697 {
696 698 Id = el.ElementKey,
697 699 ElementType = el.ElementType,
  700 + TypeAdd = el.TypeAdd,
698 701 ElementName = el.ElementName,
699 702 PosX = el.PosX,
700 703 PosY = el.PosY,
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/LabelTemplateAppService.cs
... ... @@ -157,6 +157,7 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
157 157 {
158 158 Id = e.ElementKey,
159 159 ElementType = e.ElementType,
  160 + TypeAdd = e.TypeAdd,
160 161 ElementName = e.ElementName,
161 162 PosX = e.PosX,
162 163 PosY = e.PosY,
... ... @@ -391,6 +392,7 @@ public class LabelTemplateAppService : ApplicationService, ILabelTemplateAppServ
391 392 TemplateId = templateDbId,
392 393 ElementKey = e.Id,
393 394 ElementType = e.ElementType,
  395 + TypeAdd = string.IsNullOrWhiteSpace(e.TypeAdd) ? null : e.TypeAdd.Trim(),
394 396 ElementName = elementName,
395 397 PosX = e.PosX,
396 398 PosY = e.PosY,
... ...
美国版/Food Labeling Management Platform/build/assets/index-SKuXxqbM.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-rOdDFGrB.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
... ... @@ -5,7 +5,7 @@
5 5 <meta charset="UTF-8" />
6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 7 <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-rOdDFGrB.js"></script>
  8 + <script type="module" crossorigin src="/assets/index-SKuXxqbM.js"></script>
9 9 <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css">
10 10 </head>
11 11  
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx
... ... @@ -20,6 +20,7 @@ import { skipCountForPage } from &#39;../../lib/paginationQuery&#39;;
20 20 import type { LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate';
21 21 import {
22 22 appliedLocationToEditor,
  23 + canonicalElementType,
23 24 dataEntryColumnLabel,
24 25 isDataEntryTableColumnElement,
25 26 labelElementsToApiPayload,
... ... @@ -49,7 +50,8 @@ const DATA_ENTRY_IMAGE_BOX =
49 50 'h-[100px] w-[100px] min-h-[100px] min-w-[100px] max-h-[100px] max-w-[100px] shrink-0 aspect-auto';
50 51  
51 52 function dataEntryUsesImageUpload(element: LabelElement): boolean {
52   - if (element.type === 'IMAGE' || element.type === 'QRCODE') return true;
  53 + const type = canonicalElementType(element.type);
  54 + if (type === 'IMAGE' || type === 'QRCODE') return true;
53 55 const n = (element.elementName ?? '').trim().toLowerCase();
54 56 return n.includes('qrcode');
55 57 }
... ... @@ -88,9 +90,12 @@ function DataEntryValueCell({
88 90 export function LabelTemplateDataEntryView({
89 91 templateCode,
90 92 onBack,
  93 + contextHint,
91 94 }: {
92 95 templateCode: string;
93 96 onBack: () => void;
  97 + /** 从 Labels 进入时展示:当前编辑的是哪条标签绑定的模板 */
  98 + contextHint?: string;
94 99 }) {
95 100 const [loading, setLoading] = useState(true);
96 101 const [saving, setSaving] = useState(false);
... ... @@ -140,7 +145,8 @@ export function LabelTemplateDataEntryView({
140 145 setTemplateTitle(title);
141 146 const elements = sortTemplateElementsForDisplay(
142 147 (tpl.elements ?? []) as LabelElement[],
143   - ).filter(isDataEntryTableColumnElement);
  148 + )
  149 + .filter(isDataEntryTableColumnElement);
144 150 setPrintFields(elements);
145 151 setProducts(prodRes.items ?? []);
146 152 setTypes(typeRes.items ?? []);
... ... @@ -311,6 +317,11 @@ export function LabelTemplateDataEntryView({
311 317 <h2 className="text-lg font-semibold text-gray-900 truncate" title={templateTitle}>
312 318 {templateTitle}
313 319 </h2>
  320 + {contextHint ? (
  321 + <p className="text-sm text-gray-600 truncate mt-0.5" title={contextHint}>
  322 + {contextHint}
  323 + </p>
  324 + ) : null}
314 325 </div>
315 326 <div className="flex items-center gap-2">
316 327 <Button type="button" variant="outline" className="h-10 gap-1" onClick={addRow}>
... ... @@ -331,9 +342,11 @@ export function LabelTemplateDataEntryView({
331 342 <p className="text-sm text-gray-600 py-3 shrink-0">
332 343 Bind product and label type per row. Values are saved with the template (edit API) as{' '}
333 344 <span className="font-medium">templateProductDefaults</span> (interface doc section 4.4). Only{' '}
334   - <span className="font-medium">FIXED</span> fields appear here.{' '}
335   - <span className="font-medium">AUTO_DB</span> and <span className="font-medium">PRINT_INPUT</span>{' '}
336   - are resolved at print time in the app. Column headers use{' '}
  345 + <span className="font-medium">manual input</span> controls appear here (
  346 + <span className="font-medium">PRINT_INPUT</span> and Duration series). Non-manual controls such as{' '}
  347 + <span className="font-medium">AUTO_DB / NUTRITION</span> are excluded.{' '}
  348 + <span className="font-medium">BARCODE</span> is excluded here and must be generated from print-time
  349 + input/data. Column headers use{' '}
337 350 <span className="font-medium">elementName</span>.
338 351 </p>
339 352  
... ... @@ -342,8 +355,7 @@ export function LabelTemplateDataEntryView({
342 355 <div className="p-10 text-center text-sm text-gray-500">Loading…</div>
343 356 ) : printFields.length === 0 ? (
344 357 <div className="p-10 text-center text-sm text-gray-600">
345   - No <span className="font-medium">FIXED</span> elements in this template (or none besides
346   - blanks). AUTO_DB / PRINT_INPUT columns are hidden here by design.
  358 + No manual input fields (<span className="font-medium">PRINT_INPUT / Duration series</span>) in this template.
347 359 </div>
348 360 ) : (
349 361 <Table>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/ElementsPanel.tsx
... ... @@ -9,7 +9,7 @@ const ELEMENT_CATEGORIES: {
9 9 items: { label: string; type: ElementType; config?: Record<string, unknown> }[];
10 10 }[] = [
11 11 {
12   - title: '模版信息',
  12 + title: 'Template',
13 13 items: [
14 14 { label: 'Text', type: 'TEXT_STATIC' },
15 15 { label: 'QR Code', type: 'QRCODE' },
... ... @@ -21,7 +21,7 @@ const ELEMENT_CATEGORIES: {
21 21 ],
22 22 },
23 23 {
24   - title: '标签信息',
  24 + title: 'Label',
25 25 items: [
26 26 { label: 'Label Name', type: 'TEXT_PRODUCT' },
27 27 { label: 'Text', type: 'TEXT_STATIC' },
... ... @@ -39,7 +39,7 @@ const ELEMENT_CATEGORIES: {
39 39 ],
40 40 },
41 41 {
42   - title: '自动生成',
  42 + title: 'Auto-generated',
43 43 items: [
44 44 { label: 'Company', type: 'TEXT_STATIC' },
45 45 { label: 'Employee', type: 'TEXT_STATIC' },
... ... @@ -49,13 +49,13 @@ const ELEMENT_CATEGORIES: {
49 49 ],
50 50 },
51 51 {
52   - title: '打印时输入',
53   - subtitle: '点击添加到画布',
  52 + title: 'Print input',
  53 + subtitle: 'Click to add to canvas',
54 54 items: [
55 55 { label: 'Text', type: 'TEXT_STATIC', config: { inputType: 'text' } },
56 56 { label: 'Weight', type: 'WEIGHT' },
57 57 { label: 'Number', type: 'TEXT_STATIC', config: { inputType: 'number', text: '0' } },
58   - { label: 'Date & Time', type: 'DATE', config: { inputType: 'datetime' } },
  58 + { label: 'Date & Time', type: 'DATE', config: { inputType: 'datetime', format: 'YYYY-MM-DD HH:mm' } },
59 59 {
60 60 label: 'Multiple Options',
61 61 type: 'TEXT_STATIC',
... ... @@ -63,12 +63,11 @@ const ELEMENT_CATEGORIES: {
63 63 inputType: 'options',
64 64 multipleOptionId: '',
65 65 selectedOptionValues: [],
66   - text: '文本',
  66 + text: 'Text',
67 67 fontFamily: 'Arial',
68 68 fontSize: 14,
69 69 fontWeight: 'normal',
70 70 textAlign: 'left',
71   - prefix: '¥',
72 71 },
73 72 },
74 73 ],
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
1 1 import React, { useCallback, useRef, useEffect } from 'react';
2 2 import JsBarcode from 'jsbarcode';
3 3 import { QRCodeSVG } from 'qrcode.react';
4   -import type { LabelTemplate, LabelElement, ElementType } from '../../../types/labelTemplate';
5   -import { isPrintInputElement } from '../../../types/labelTemplate';
  4 +import type {
  5 + LabelTemplate,
  6 + LabelElement,
  7 + NutritionExtraItem,
  8 +} from '../../../types/labelTemplate';
  9 +import { canonicalElementType, isPrintInputElement } from '../../../types/labelTemplate';
6 10 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate';
  11 +import { NUTRITION_FIXED_ITEMS } from '../../../types/labelTemplate';
7 12 import { cn } from '../../ui/utils';
8 13 import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService';
9 14 import {
... ... @@ -111,10 +116,111 @@ function formatMultipleOptionsCanvasLine(
111 116 return answers || fallback;
112 117 }
113 118  
  119 +function nutritionExtraRows(cfg: Record<string, unknown>): NutritionExtraItem[] {
  120 + const raw = cfg.extraNutrients;
  121 + if (!Array.isArray(raw)) return [];
  122 + return raw.map((item, idx) => {
  123 + const row = item as Record<string, unknown>;
  124 + return {
  125 + id: String(row.id ?? `extra-${idx}`),
  126 + name: String(row.name ?? ''),
  127 + value: String(row.value ?? ''),
  128 + unit: String(row.unit ?? ''),
  129 + };
  130 + });
  131 +}
  132 +
  133 +function nutritionFixedField(
  134 + cfg: Record<string, unknown>,
  135 + key: string,
  136 + field: 'value' | 'unit',
  137 +): string {
  138 + const directKey = field === 'value' ? key : `${key}Unit`;
  139 + const direct = cfg[directKey];
  140 + if (direct != null && String(direct).trim() !== '') return String(direct).trim();
  141 + const fixedRows = Array.isArray(cfg.fixedNutrients)
  142 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  143 + : [];
  144 + const row = fixedRows.find((item) => String(item.key ?? '').trim() === key);
  145 + return String(row?.[field] ?? '').trim();
  146 +}
  147 +
  148 +function formatDateByPreset(format: string, date: Date): string {
  149 + const yyyy = String(date.getFullYear());
  150 + const yy = yyyy.slice(-2);
  151 + const mm = String(date.getMonth() + 1).padStart(2, '0');
  152 + const dd = String(date.getDate()).padStart(2, '0');
  153 + const hh = String(date.getHours()).padStart(2, '0');
  154 + const min = String(date.getMinutes()).padStart(2, '0');
  155 + const monthLong = date.toLocaleString('en-US', { month: 'long' }).toUpperCase();
  156 + const dayLong = date.toLocaleString('en-US', { weekday: 'long' }).toUpperCase();
  157 + const dayShort = date.toLocaleString('en-US', { weekday: 'short' }).toUpperCase();
  158 + const monthShort = date.toLocaleString('en-US', { month: 'short' }).toUpperCase();
  159 + switch (format) {
  160 + case 'DD/MM/YYYY':
  161 + return `${dd}/${mm}/${yyyy}`;
  162 + case 'MM/DD/YYYY':
  163 + return `${mm}/${dd}/${yyyy}`;
  164 + case 'DD/MM/YY':
  165 + return `${dd}/${mm}/${yy}`;
  166 + case 'MM/DD/YY':
  167 + return `${mm}/${dd}/${yy}`;
  168 + case 'MM/YY':
  169 + return `${mm}/${yy}`;
  170 + case 'MM/DD':
  171 + return `${mm}/${dd}`;
  172 + case 'MM':
  173 + return mm;
  174 + case 'DD':
  175 + return dd;
  176 + case 'YY':
  177 + return yy;
  178 + case 'FULLY DAY(WEDNESDAY)':
  179 + return dayLong;
  180 + case 'DAY (WED)':
  181 + return dayShort;
  182 + case 'MONTH (DECEMBER)':
  183 + return monthLong;
  184 + case 'YEAR (2025)':
  185 + return yyyy;
  186 + case 'DD MONTH YEAR (25 DECEMBER 2025)':
  187 + return `${dd} ${monthLong} ${yyyy}`;
  188 + default:
  189 + return format
  190 + .replace('YYYY', yyyy)
  191 + .replace('YY', yy)
  192 + .replace('MM', mm)
  193 + .replace('DD', dd)
  194 + .replace('HH', hh)
  195 + .replace('mm', min);
  196 + }
  197 +}
  198 +
  199 +const DURATION_UNITS = new Set([
  200 + 'Minutes',
  201 + 'Hours',
  202 + 'Days',
  203 + 'Weeks',
  204 + 'Months (30 Day)',
  205 + 'Years',
  206 +]);
  207 +
  208 +function normalizeWeightUnit(raw: unknown): 'lb' | 'kg' | 'mg' | 'g' | 'oz' {
  209 + const unit = String(raw ?? '').trim().toLowerCase();
  210 + if (unit === 'milligrams') return 'mg';
  211 + if (unit === 'grams') return 'g';
  212 + if (unit === 'ounces') return 'oz';
  213 + if (unit === 'pounds') return 'lb';
  214 + if (unit === 'kilograms') return 'kg';
  215 + if (unit === 'lb' || unit === 'kg' || unit === 'mg' || unit === 'g' || unit === 'oz') return unit;
  216 + return 'g';
  217 +}
  218 +
114 219 /** 根据元素类型与 config 渲染画布上的默认内容 */
115 220 function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintField?: boolean }) {
116 221 const cfg = el.config as Record<string, unknown>;
117   - const type = el.type as ElementType;
  222 + const type = canonicalElementType(el.type);
  223 + const isVerticalRotation = el.rotation === 'vertical';
118 224  
119 225 // Common styles
120 226 const commonStyle: React.CSSProperties = {
... ... @@ -125,10 +231,23 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
125 231 color: (cfg?.color as string) ?? '#000',
126 232 };
127 233  
  234 + // Rotation support:
  235 + // The editor's Rotation is currently a simple horizontal/vertical toggle.
  236 + // For text-like elements we render vertical via writing-mode to avoid layout clipping.
  237 + const textLike =
  238 + type === 'TEXT_STATIC' || type === 'TEXT_PRODUCT' || type === 'TEXT_PRICE';
  239 + const textRotationStyle: React.CSSProperties =
  240 + isVerticalRotation && textLike
  241 + ? { writingMode: 'vertical-rl', textOrientation: 'mixed' as any }
  242 + : {};
  243 + const rotateBoxStyle: React.CSSProperties = isVerticalRotation
  244 + ? { transform: 'rotate(-90deg)', transformOrigin: 'center center' }
  245 + : {};
  246 +
128 247 // 文本类
129 248 const inputType = cfg?.inputType as string | undefined;
130 249 if (type === 'TEXT_STATIC') {
131   - const text = (cfg?.text as string) ?? '文本';
  250 + const text = (cfg?.text as string) ?? 'Text';
132 251 if (isAppPrintField) {
133 252 if (inputType === 'options') {
134 253 const selected = Array.isArray(cfg?.selectedOptionValues)
... ... @@ -138,7 +257,7 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
138 257 return (
139 258 <div
140 259 className="w-full h-full px-1 flex flex-col justify-center overflow-hidden pointer-events-none text-gray-600 italic text-[11px] leading-tight break-all"
141   - style={commonStyle}
  260 + style={{ ...commonStyle, ...textRotationStyle }}
142 261 title="Filled in mobile app when printing"
143 262 >
144 263 {line}
... ... @@ -150,7 +269,7 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
150 269 return (
151 270 <div
152 271 className="w-full h-full px-1 flex items-center overflow-hidden pointer-events-none text-gray-600 italic text-[11px]"
153   - style={commonStyle}
  272 + style={{ ...commonStyle, ...textRotationStyle }}
154 273 title="Filled in mobile app when printing"
155 274 >
156 275 {display}
... ... @@ -164,7 +283,7 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
164 283 readOnly
165 284 value={(cfg?.text as string) ?? '0'}
166 285 className="w-full h-full min-w-0 border border-gray-300 bg-white rounded px-1 pointer-events-none"
167   - style={{ ...commonStyle, textAlign: 'right' }}
  286 + style={{ ...commonStyle, ...textRotationStyle, textAlign: 'right' }}
168 287 />
169 288 );
170 289 }
... ... @@ -181,7 +300,7 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
181 300 'w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight',
182 301 muted && 'text-gray-400',
183 302 )}
184   - style={commonStyle}
  303 + style={{ ...commonStyle, ...textRotationStyle }}
185 304 title={line}
186 305 >
187 306 {line}
... ... @@ -195,30 +314,46 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
195 314 readOnly
196 315 value={text}
197 316 className="w-full h-full min-w-0 border border-gray-300 bg-white rounded px-1 pointer-events-none"
198   - style={commonStyle}
  317 + style={{ ...commonStyle, ...textRotationStyle }}
199 318 />
200 319 );
201 320 }
202 321 return (
203   - <div className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight" style={commonStyle}>
  322 + <div
  323 + className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight"
  324 + style={{ ...commonStyle, ...textRotationStyle }}
  325 + >
204 326 {text}
205 327 </div>
206 328 );
207 329 }
208 330 if (type === 'TEXT_PRODUCT') {
209   - const text = (cfg?.text as string) ?? '商品名';
  331 + const text = (cfg?.text as string) ?? 'Product name';
210 332 return (
211   - <div className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight" style={commonStyle}>
  333 + <div
  334 + className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight"
  335 + style={{ ...commonStyle, ...textRotationStyle }}
  336 + >
212 337 {text}
213 338 </div>
214 339 );
215 340 }
216 341 if (type === 'TEXT_PRICE') {
217   - const prefix = (cfg?.prefix as string) ?? '¥';
218 342 const text = (cfg?.text as string) ?? '0.00';
219 343 return (
220   - <div className="w-full h-full px-1 overflow-hidden flex items-center" style={{ ...commonStyle, justifyContent: commonStyle.textAlign === 'center' ? 'center' : commonStyle.textAlign === 'right' ? 'flex-end' : 'flex-start' }}>
221   - <span>{prefix}</span>
  344 + <div
  345 + className="w-full h-full px-1 overflow-hidden flex items-center"
  346 + style={{
  347 + ...commonStyle,
  348 + ...textRotationStyle,
  349 + justifyContent:
  350 + commonStyle.textAlign === 'center'
  351 + ? 'center'
  352 + : commonStyle.textAlign === 'right'
  353 + ? 'flex-end'
  354 + : 'flex-start',
  355 + }}
  356 + >
222 357 <span>{text}</span>
223 358 </div>
224 359 );
... ... @@ -258,17 +393,26 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
258 393 // 图片/Logo
259 394 if (type === 'IMAGE') {
260 395 const src = cfg?.src as string | undefined;
  396 + const imageRotateStyle: React.CSSProperties = isVerticalRotation
  397 + ? { transform: 'rotate(-90deg)' }
  398 + : {};
261 399 if (src) {
262 400 return (
263   - <img
264   - src={resolvePictureUrlForDisplay(src)}
265   - alt=""
266   - className="w-full h-full object-contain"
267   - />
  401 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  402 + <img
  403 + src={resolvePictureUrlForDisplay(src)}
  404 + alt=""
  405 + className="max-w-full max-h-full object-contain"
  406 + style={imageRotateStyle}
  407 + />
  408 + </div>
268 409 );
269 410 }
270 411 return (
271   - <div className="w-full h-full flex flex-col items-center justify-center bg-gray-100 text-gray-500 text-[10px] border border-dashed border-gray-300">
  412 + <div
  413 + className="w-full h-full flex flex-col items-center justify-center bg-gray-100 text-gray-500 text-[10px] border border-dashed border-gray-300"
  414 + style={imageRotateStyle}
  415 + >
272 416 <span className="font-medium">Logo</span>
273 417 </div>
274 418 );
... ... @@ -276,54 +420,86 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
276 420  
277 421 // 日期/时间
278 422 if (type === 'DATE') {
  423 + const it = String(cfg?.inputType ?? cfg?.InputType ?? '').toLowerCase();
279 424 const format =
280 425 (typeof cfg?.format === 'string' && cfg.format.trim()
281 426 ? cfg.format
282 427 : typeof cfg?.Format === 'string' && cfg.Format.trim()
283 428 ? cfg.Format
284   - : 'YYYY-MM-DD') ?? 'YYYY-MM-DD';
285   - const example = format.replace('YYYY', '2025').replace('MM', '02').replace('DD', '01');
286   - const it = String(cfg?.inputType ?? cfg?.InputType ?? '').toLowerCase();
  429 + : it === 'datetime'
  430 + ? 'YYYY-MM-DD HH:mm'
  431 + : 'DD/MM/YYYY') ?? (it === 'datetime' ? 'YYYY-MM-DD HH:mm' : 'DD/MM/YYYY');
  432 + const offset = Number(cfg?.offsetDays ?? cfg?.OffsetDays ?? 0) || 0;
  433 + const d = new Date();
  434 + d.setDate(d.getDate() + offset);
  435 + const example = formatDateByPreset(format, d);
287 436 const isInput = it === 'datetime' || it === 'date';
288 437 if (isInput) {
289 438 if (isAppPrintField) {
290 439 return (
291   - <div
292   - className="w-full h-full px-1 flex items-center justify-center overflow-hidden pointer-events-none text-[10px] text-center whitespace-nowrap"
293   - style={commonStyle}
294   - title={`Format: ${format}`}
295   - >
296   - {format}
  440 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  441 + <div
  442 + className="px-1 flex items-center justify-center overflow-hidden pointer-events-none text-[10px] text-center whitespace-nowrap"
  443 + style={{ ...commonStyle, ...rotateBoxStyle }}
  444 + title={`Format: ${format}`}
  445 + >
  446 + {format}
  447 + </div>
297 448 </div>
298 449 );
299 450 }
300 451 return (
301   - <input
302   - type="date"
303   - readOnly
304   - value="2025-02-01"
305   - className="w-full h-full min-w-0 border border-gray-300 bg-white rounded px-1 pointer-events-none text-[10px]"
306   - style={commonStyle}
307   - />
  452 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  453 + <input
  454 + type="date"
  455 + readOnly
  456 + value="2025-02-01"
  457 + className="w-full h-full min-w-0 border border-gray-300 bg-white rounded px-1 pointer-events-none text-[10px]"
  458 + style={{ ...commonStyle, ...rotateBoxStyle }}
  459 + />
  460 + </div>
308 461 );
309 462 }
310   - return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>{example}</div>;
  463 + return (
  464 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  465 + <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>
  466 + {example}
  467 + </div>
  468 + </div>
  469 + );
311 470 }
312 471  
313 472 // (Simplified other types similarly for brevity, ensuring style prop is passed)
314 473 if (type === 'TIME') {
315   - const format =
316   - (typeof cfg?.format === 'string' && cfg.format.trim()
317   - ? cfg.format
318   - : typeof cfg?.Format === 'string' && cfg.Format.trim()
319   - ? cfg.Format
320   - : 'HH:mm') ?? 'HH:mm';
  474 + const format = 'HH:mm';
321 475 const example = format.replace('HH', '12').replace('mm', '30');
322   - return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>{example}</div>;
  476 + return (
  477 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  478 + <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>
  479 + {example}
  480 + </div>
  481 + </div>
  482 + );
323 483 }
324 484  
325 485 if (type === 'DURATION') {
326   - return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>保质期 2025-02-04</div>;
  486 + const rawFormat =
  487 + (typeof cfg?.format === 'string' && cfg.format.trim()
  488 + ? cfg.format
  489 + : typeof cfg?.Format === 'string' && cfg.Format.trim()
  490 + ? cfg.Format
  491 + : 'Days') ?? 'Days';
  492 + const unit = DURATION_UNITS.has(rawFormat) ? rawFormat : 'Days';
  493 + const rawV = cfg?.durationValue ?? cfg?.value ?? cfg?.offsetDays ?? cfg?.DurationValue ?? cfg?.Value ?? cfg?.OffsetDays;
  494 + const durationValue = Number.isFinite(Number(rawV)) ? Number(rawV) : 3;
  495 + const example = `${durationValue} ${unit}`;
  496 + return (
  497 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  498 + <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>
  499 + {example}
  500 + </div>
  501 + </div>
  502 + );
327 503 }
328 504  
329 505 if (type === 'WEIGHT') {
... ... @@ -335,16 +511,27 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
335 511 ? rawV
336 512 : Number(rawV);
337 513 const weightNum = Number.isFinite(numVal) ? numVal : 500;
338   - const weightUnit =
  514 + const weightUnit = normalizeWeightUnit(
339 515 (typeof cfg?.unit === 'string' && cfg.unit.trim()
340 516 ? cfg.unit
341 517 : typeof cfg?.Unit === 'string' && cfg.Unit.trim()
342 518 ? cfg.Unit
343   - : 'g') ?? 'g';
  519 + : 'g') ?? 'g',
  520 + );
  521 + const weightFontSizeRaw = cfg?.fontSize ?? cfg?.FontSize;
  522 + const weightFontSize = Number.isFinite(Number(weightFontSizeRaw)) ? Number(weightFontSizeRaw) : 14;
  523 + const weightTextAlignRaw = String(cfg?.textAlign ?? cfg?.TextAlign ?? 'left').toLowerCase();
  524 + const weightTextAlign: 'left' | 'center' | 'right' =
  525 + weightTextAlignRaw === 'center' || weightTextAlignRaw === 'right' ? weightTextAlignRaw : 'left';
344 526 return (
345   - <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>
346   - {weightNum}
347   - {weightUnit}
  527 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  528 + <div
  529 + className="px-1 overflow-hidden whitespace-nowrap"
  530 + style={{ ...commonStyle, ...rotateBoxStyle, fontSize: weightFontSize, textAlign: weightTextAlign }}
  531 + >
  532 + {weightNum}
  533 + {weightUnit}
  534 + </div>
348 535 </div>
349 536 );
350 537 }
... ... @@ -352,17 +539,98 @@ function ElementContent({ el, isAppPrintField }: { el: LabelElement; isAppPrintF
352 539 if (type === 'WEIGHT_PRICE') {
353 540 const unitPrice = (cfg?.unitPrice as number) ?? 10;
354 541 const weight = (cfg?.weight as number) ?? 0.5;
355   - const currency = (cfg?.currency as string) ?? '';
  542 + const currency = (cfg?.currency as string) ?? '$';
356 543 return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>{currency}{(unitPrice * weight).toFixed(2)}</div>;
357 544 }
358 545  
359 546 // 营养成分表
360 547 if (type === 'NUTRITION') {
361   - const calories = (cfg?.calories as number) ?? 120;
  548 + const servingsPerContainer = String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? '').trim();
  549 + const servingSize = String(cfg.servingSize ?? cfg.ServingSize ?? '').trim();
  550 + const calories = String(cfg.calories ?? cfg.Calories ?? nutritionFixedField(cfg, 'calories', 'value') ?? '').trim();
  551 + const nutritionTitleSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16;
  552 + const baseRows = NUTRITION_FIXED_ITEMS.map((item) => {
  553 + const value = nutritionFixedField(cfg, item.key, 'value');
  554 + const unit = nutritionFixedField(cfg, item.key, 'unit');
  555 + if (!value) return null;
  556 + return {
  557 + id: item.key,
  558 + label: item.label,
  559 + value,
  560 + unit,
  561 + };
  562 + }).filter(Boolean) as Array<{ id: string; label: string; value: string; unit: string }>;
  563 + const extraRows = nutritionExtraRows(cfg)
  564 + .filter((item) => item.value.trim())
  565 + .map((item) => ({
  566 + id: item.id,
  567 + label: item.name.trim() || 'Other',
  568 + value: item.value.trim(),
  569 + unit: item.unit.trim(),
  570 + }));
  571 + const rows = [...baseRows, ...extraRows];
  572 + const formatNutritionValue = (value: string, unit: string): string => {
  573 + const v = String(value ?? '').trim();
  574 + const u = String(unit ?? '').trim();
  575 + if (!v && !u) return '';
  576 + return `<${v}${u ? ` ${u}` : ''}`;
  577 + };
  578 + const nutritionContent = (
  579 + <div className="text-[10px] p-1 w-full h-full overflow-hidden flex flex-col leading-tight bg-white">
  580 + <div className="font-bold border-b border-black pb-0.5" style={{ fontSize: `${nutritionTitleSize}px` }}>
  581 + Nutrition Facts
  582 + </div>
  583 + {calories ? (
  584 + <div className="flex items-center justify-between py-0.5 mt-0.5">
  585 + <span className="font-semibold text-[10px]">Calories</span>
  586 + <span className="font-semibold text-[10px]">{formatNutritionValue(calories, '')}</span>
  587 + </div>
  588 + ) : null}
  589 + {servingsPerContainer ? (
  590 + <div className="flex items-center justify-between py-0.5 text-[10px]">
  591 + <span>Servings Per Container</span>
  592 + <span>{servingsPerContainer}</span>
  593 + </div>
  594 + ) : null}
  595 + {servingSize ? (
  596 + <div className="flex items-center justify-between pb-0.5 text-[10px]">
  597 + <span>Serving Size</span>
  598 + <span>{servingSize}</span>
  599 + </div>
  600 + ) : null}
  601 + <div className="flex-1 min-h-0 overflow-hidden pt-0.5">
  602 + {rows.length === 0 ? (
  603 + <div className="text-[7px] text-gray-500">No nutrients</div>
  604 + ) : (
  605 + rows.slice(0, 18).map((row) => (
  606 + <div key={row.id} className="flex items-center justify-between py-[1px] text-[10px]">
  607 + <span className="truncate font-medium">{row.label}</span>
  608 + <span className="shrink-0 font-medium">
  609 + {formatNutritionValue(row.value, row.unit)}
  610 + </span>
  611 + </div>
  612 + ))
  613 + )}
  614 + </div>
  615 + </div>
  616 + );
362 617 return (
363   - <div className="text-[8px] p-0.5 w-full h-full overflow-hidden flex flex-col">
364   - <div className="font-semibold border-b border-black">Nutrition Facts</div>
365   - <div>Calories {calories}</div>
  618 + <div className="w-full h-full flex items-center justify-center overflow-hidden">
  619 + <div
  620 + className="shrink-0"
  621 + style={
  622 + isVerticalRotation
  623 + ? {
  624 + width: el.height,
  625 + height: el.width,
  626 + transform: 'rotate(-90deg)',
  627 + transformOrigin: 'center center',
  628 + }
  629 + : { width: '100%', height: '100%' }
  630 + }
  631 + >
  632 + {nutritionContent}
  633 + </div>
366 634 </div>
367 635 );
368 636 }
... ... @@ -392,6 +660,23 @@ interface LabelCanvasProps {
392 660 onPreview?: () => void;
393 661 }
394 662  
  663 +type PaperResizeEdge =
  664 + | 'bottom'
  665 + | 'right'
  666 + | 'top'
  667 + | 'left'
  668 + | 'top-left'
  669 + | 'top-right'
  670 + | 'bottom-left'
  671 + | 'bottom-right';
  672 +
  673 +function cursorForPaperResizeEdge(edge: PaperResizeEdge): string {
  674 + if (edge === 'top' || edge === 'bottom') return 'ns-resize';
  675 + if (edge === 'left' || edge === 'right') return 'ew-resize';
  676 + if (edge === 'top-left' || edge === 'bottom-right') return 'nwse-resize';
  677 + return 'nesw-resize';
  678 +}
  679 +
395 680 export function LabelCanvas({
396 681 template,
397 682 selectedId,
... ... @@ -408,12 +693,20 @@ export function LabelCanvas({
408 693 const canvasRef = useRef<HTMLDivElement>(null);
409 694 const dragRef = useRef<{ id: string; startX: number; startY: number; elX: number; elY: number } | null>(null);
410 695 const resizeRef = useRef<{ id: string; corner: string; startX: number; startY: number; w: number; h: number; elX: number; elY: number } | null>(null);
411   - const paperResizeRef = useRef<{ edge: 'bottom' | 'right'; startX: number; startY: number; startW: number; startH: number } | null>(null);
  696 + const paperResizeRef = useRef<{
  697 + edge: PaperResizeEdge;
  698 + startX: number;
  699 + startY: number;
  700 + startW: number;
  701 + startH: number;
  702 + startElements: { id: string; x: number; y: number }[];
  703 + } | null>(null);
412 704 const lastUpdateRef = useRef<{ id: string; x?: number; y?: number; width?: number; height?: number } | null>(null);
413 705  
414 706 const nextFrameRef = useRef<number | null>(null);
415 707 const [isSpacePressed, setIsSpacePressed] = React.useState(false);
416 708 const [isPanning, setIsPanning] = React.useState(false);
  709 + const [paperResizeCursor, setPaperResizeCursor] = React.useState<string | null>(null);
417 710 const panStartRef = useRef<{ x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
418 711 const [panOffset, setPanOffset] = React.useState({ x: 0, y: 0 });
419 712 const panOffsetStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
... ... @@ -462,6 +755,22 @@ export function LabelCanvas({
462 755 });
463 756 }, []);
464 757  
  758 + const beginPaperResize = useCallback((e: React.PointerEvent, edge: PaperResizeEdge) => {
  759 + e.stopPropagation();
  760 + paperResizeRef.current = {
  761 + edge,
  762 + startX: e.clientX,
  763 + startY: e.clientY,
  764 + startW: template.width,
  765 + startH: template.height,
  766 + startElements: template.elements.map((el) => ({ id: el.id, x: el.x, y: el.y })),
  767 + };
  768 + const cursor = cursorForPaperResizeEdge(edge);
  769 + setPaperResizeCursor(cursor);
  770 + document.body.style.cursor = cursor;
  771 + (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
  772 + }, [template.width, template.height, template.elements]);
  773 +
465 774 const handlePointerMove = useCallback(
466 775 (e: React.PointerEvent) => {
467 776 // 画布平移:优先处理(translate 方式,不依赖滚动)
... ... @@ -551,22 +860,69 @@ export function LabelCanvas({
551 860  
552 861 // Resize Paper
553 862 if (paperResizeRef.current && onTemplateChange) {
554   - const { edge, startX, startY, startW, startH } = paperResizeRef.current;
  863 + const { edge, startX, startY, startW, startH, startElements } = paperResizeRef.current;
555 864 const clientX = e.clientX;
556 865 const clientY = e.clientY;
557 866  
558 867 requestUpdate(() => {
559 868 const deltaPxX = (clientX - startX) / scale;
560 869 const deltaPxY = (clientY - startY) / scale;
561   - const deltaUnitX = pxToUnit(deltaPxX, template.unit);
562   - const deltaUnitY = pxToUnit(deltaPxY, template.unit);
563   - if (edge === 'bottom') {
564   - const newH = Math.max(1, startH + deltaUnitY);
565   - onTemplateChange({ height: newH });
566   - } else {
567   - const newW = Math.max(1, startW + deltaUnitX);
568   - onTemplateChange({ width: newW });
  870 +
  871 + const minPaperUnit = 1;
  872 + const startWPx = unitToPx(startW, template.unit);
  873 + const startHPx = unitToPx(startH, template.unit);
  874 + const minWPx = unitToPx(minPaperUnit, template.unit);
  875 + const minHPx = unitToPx(minPaperUnit, template.unit);
  876 +
  877 + const affectsTop = edge === 'top' || edge === 'top-left' || edge === 'top-right';
  878 + const affectsBottom = edge === 'bottom' || edge === 'bottom-left' || edge === 'bottom-right';
  879 + const affectsLeft = edge === 'left' || edge === 'top-left' || edge === 'bottom-left';
  880 + const affectsRight = edge === 'right' || edge === 'top-right' || edge === 'bottom-right';
  881 +
  882 + let nextWUnit = startW;
  883 + let nextHUnit = startH;
  884 + let offsetContentPxX = 0;
  885 + let offsetContentPxY = 0;
  886 +
  887 + if (affectsRight) {
  888 + const proposedPx = Math.max(minWPx, startWPx + deltaPxX);
  889 + nextWUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
569 890 }
  891 + if (affectsBottom) {
  892 + const proposedPx = Math.max(minHPx, startHPx + deltaPxY);
  893 + nextHUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
  894 + }
  895 + if (affectsLeft) {
  896 + const proposedPx = Math.max(minWPx, startWPx - deltaPxX);
  897 + nextWUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
  898 + const snappedPx = unitToPx(nextWUnit, template.unit);
  899 + const appliedDelta = startWPx - snappedPx;
  900 + offsetContentPxX = appliedDelta;
  901 + }
  902 + if (affectsTop) {
  903 + const proposedPx = Math.max(minHPx, startHPx - deltaPxY);
  904 + nextHUnit = Math.max(minPaperUnit, Math.round(pxToUnit(proposedPx, template.unit)));
  905 + const snappedPx = unitToPx(nextHUnit, template.unit);
  906 + const appliedDelta = startHPx - snappedPx;
  907 + offsetContentPxY = appliedDelta;
  908 + }
  909 +
  910 + const patch: Partial<LabelTemplate> = {};
  911 + if (affectsLeft || affectsRight) patch.width = nextWUnit;
  912 + if (affectsTop || affectsBottom) patch.height = nextHUnit;
  913 +
  914 + if ((offsetContentPxX !== 0 || offsetContentPxY !== 0) && startElements.length > 0) {
  915 + const byId = new Map(startElements.map(s => [s.id, s]));
  916 + patch.elements = template.elements.map((el) => {
  917 + const s = byId.get(el.id);
  918 + if (!s) return el;
  919 + const nx = Math.max(0, s.x - offsetContentPxX);
  920 + const ny = Math.max(0, s.y - offsetContentPxY);
  921 + return (nx === el.x && ny === el.y) ? el : { ...el, x: nx, y: ny };
  922 + });
  923 + }
  924 +
  925 + onTemplateChange(patch);
570 926 });
571 927 }
572 928 },
... ... @@ -603,6 +959,8 @@ export function LabelCanvas({
603 959 dragRef.current = null;
604 960 resizeRef.current = null;
605 961 paperResizeRef.current = null;
  962 + setPaperResizeCursor(null);
  963 + document.body.style.cursor = '';
606 964 }, [onUpdateElement]);
607 965  
608 966 useEffect(() => {
... ... @@ -627,6 +985,14 @@ export function LabelCanvas({
627 985 };
628 986 }, []);
629 987  
  988 + useEffect(() => {
  989 + if (!paperResizeCursor) return;
  990 + document.body.style.cursor = paperResizeCursor;
  991 + return () => {
  992 + document.body.style.cursor = '';
  993 + };
  994 + }, [paperResizeCursor]);
  995 +
630 996 // 画布初始居中:挂载或尺寸/缩放变化后让内容居中
631 997 useEffect(() => {
632 998 const el = scrollContainerRef.current;
... ... @@ -734,7 +1100,7 @@ export function LabelCanvas({
734 1100 onClick={onPreview}
735 1101 className="h-8 px-3 rounded border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 text-xs font-medium shadow-sm transition-all active:scale-95"
736 1102 >
737   - 预览
  1103 + Preview
738 1104 </button>
739 1105 )}
740 1106 {onTemplateChange && (
... ... @@ -753,7 +1119,7 @@ export function LabelCanvas({
753 1119 }}
754 1120 >
755 1121 <SelectTrigger className="h-8 w-[130px] text-xs">
756   - <SelectValue placeholder="画布大小" />
  1122 + <SelectValue placeholder="Canvas size" />
757 1123 </SelectTrigger>
758 1124 <SelectContent>
759 1125 {PRESET_LABEL_SIZES.map((p, i) => (
... ... @@ -762,7 +1128,7 @@ export function LabelCanvas({
762 1128 </SelectItem>
763 1129 ))}
764 1130 <SelectItem value="custom" className="text-xs text-gray-500">
765   - 自定义
  1131 + Custom
766 1132 </SelectItem>
767 1133 </SelectContent>
768 1134 </Select>
... ... @@ -774,7 +1140,7 @@ export function LabelCanvas({
774 1140 showGrid ? 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50' : 'border-gray-300 bg-gray-100 text-gray-500'
775 1141 )}
776 1142 >
777   - {showGrid ? '隐藏网格' : '显示网格'}
  1143 + {showGrid ? 'Hide grid' : 'Show grid'}
778 1144 </button>
779 1145 </>
780 1146 )}
... ... @@ -784,7 +1150,7 @@ export function LabelCanvas({
784 1150 onClick={onZoomOut}
785 1151 disabled={!onZoomOut}
786 1152 className="h-6 w-6 rounded hover:bg-gray-100 text-gray-600 disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center text-sm font-medium active:scale-90 transition-transform"
787   - title="缩小"
  1153 + title="Zoom out"
788 1154 >
789 1155
790 1156 </button>
... ... @@ -796,7 +1162,7 @@ export function LabelCanvas({
796 1162 onClick={onZoomIn}
797 1163 disabled={!onZoomIn}
798 1164 className="h-6 w-6 rounded hover:bg-gray-100 text-gray-600 disabled:opacity-50 disabled:pointer-events-none flex items-center justify-center text-sm font-medium active:scale-90 transition-transform"
799   - title="放大"
  1165 + title="Zoom in"
800 1166 >
801 1167 +
802 1168 </button>
... ... @@ -845,13 +1211,18 @@ export function LabelCanvas({
845 1211 : undefined,
846 1212 backgroundSize: showGrid ? `${GRID_SIZE}px ${GRID_SIZE}px` : undefined,
847 1213 // 如果按住空格,禁用 canvas 内部的 pointer-events 以便拖动容器
848   - pointerEvents: isSpacePressed ? 'none' : 'auto'
  1214 + pointerEvents: isSpacePressed ? 'none' : 'auto',
  1215 + cursor: paperResizeCursor ?? undefined,
849 1216 }}
850 1217 onClick={(e) => {
851 1218 // 点击画布空白处取消选中
852 1219 const target = e.target as HTMLElement;
853 1220 const isOnElement = target.closest('[id^="element-"]');
854   - const isOnPaperResize = target.closest('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]');
  1221 + const isOnPaperResize =
  1222 + target.closest('[data-paper-resize-handle="true"]') ||
  1223 + target.closest('[title*="Drag to resize paper"]') ||
  1224 + target.closest('[title*="Drag to increase paper height"]') ||
  1225 + target.closest('[title*="Drag to increase paper width"]');
855 1226 if (!isOnElement && !isOnPaperResize) {
856 1227 onSelect(null);
857 1228 }
... ... @@ -860,7 +1231,11 @@ export function LabelCanvas({
860 1231 // 空白处或标尺等非控件区域按下即开始平移(放宽判定:在画布内且未点到元素/纸张拖拽条)
861 1232 const target = e.target as HTMLElement;
862 1233 const isOnElement = target.closest('[id^="element-"]');
863   - const isOnPaperResize = target.closest('[title*="拖拽拉高"]') || target.closest('[title*="拖拽拉宽"]');
  1234 + const isOnPaperResize =
  1235 + target.closest('[data-paper-resize-handle="true"]') ||
  1236 + target.closest('[title*="Drag to resize paper"]') ||
  1237 + target.closest('[title*="Drag to increase paper height"]') ||
  1238 + target.closest('[title*="Drag to increase paper width"]');
864 1239 const isOnCanvasArea = canvasRef.current?.contains(target);
865 1240 if (isOnCanvasArea && !isOnElement && !isOnPaperResize && !dragRef.current && !resizeRef.current) {
866 1241 // 如果按住空格或中键,开始平移
... ... @@ -893,22 +1268,70 @@ export function LabelCanvas({
893 1268 {template.unit} {template.width} × {template.height}
894 1269 </div>
895 1270 )}
  1271 + {/* Paper resize: top */}
  1272 + {onTemplateChange && (
  1273 + <div
  1274 + className="absolute left-0 right-0 h-3 cursor-ns-resize flex items-center justify-center bg-gray-200/80 hover:bg-blue-400/30 border-b border-gray-300 text-[10px] text-gray-500 transition-colors"
  1275 + style={{ top: template.showRuler ? 20 : 0 }}
  1276 + title="Drag to resize paper (top edge)"
  1277 + data-paper-resize-handle="true"
  1278 + onPointerDown={(e) => beginPaperResize(e, 'top')}
  1279 + >
  1280 + ⋮
  1281 + </div>
  1282 + )}
  1283 + {/* Paper resize: left */}
  1284 + {onTemplateChange && (
  1285 + <div
  1286 + className="absolute top-0 bottom-0 w-3 cursor-ew-resize flex items-center justify-center bg-gray-200/80 hover:bg-blue-400/30 border-r border-gray-300 text-[10px] text-gray-500 transition-colors"
  1287 + style={{ top: template.showRuler ? 20 : 0 }}
  1288 + title="Drag to resize paper (left edge)"
  1289 + data-paper-resize-handle="true"
  1290 + onPointerDown={(e) => beginPaperResize(e, 'left')}
  1291 + >
  1292 + ⋮
  1293 + </div>
  1294 + )}
  1295 + {/* Paper resize: corners (optional but helpful) */}
  1296 + {onTemplateChange && (
  1297 + <>
  1298 + <div
  1299 + className="absolute w-3 h-3 bg-gray-200/90 hover:bg-blue-400/30 border border-gray-300 cursor-nwse-resize transition-colors"
  1300 + style={{ left: 0, top: template.showRuler ? 20 : 0 }}
  1301 + title="Drag to resize paper (top-left corner)"
  1302 + data-paper-resize-handle="true"
  1303 + onPointerDown={(e) => beginPaperResize(e, 'top-left')}
  1304 + />
  1305 + <div
  1306 + className="absolute w-3 h-3 bg-gray-200/90 hover:bg-blue-400/30 border border-gray-300 cursor-nesw-resize transition-colors"
  1307 + style={{ right: 0, top: template.showRuler ? 20 : 0 }}
  1308 + title="Drag to resize paper (top-right corner)"
  1309 + data-paper-resize-handle="true"
  1310 + onPointerDown={(e) => beginPaperResize(e, 'top-right')}
  1311 + />
  1312 + <div
  1313 + className="absolute w-3 h-3 bg-gray-200/90 hover:bg-blue-400/30 border border-gray-300 cursor-nesw-resize transition-colors"
  1314 + style={{ left: 0, bottom: 0 }}
  1315 + title="Drag to resize paper (bottom-left corner)"
  1316 + data-paper-resize-handle="true"
  1317 + onPointerDown={(e) => beginPaperResize(e, 'bottom-left')}
  1318 + />
  1319 + <div
  1320 + className="absolute w-3 h-3 bg-gray-200/90 hover:bg-blue-400/30 border border-gray-300 cursor-nwse-resize transition-colors"
  1321 + style={{ right: 0, bottom: 0 }}
  1322 + title="Drag to resize paper (bottom-right corner)"
  1323 + data-paper-resize-handle="true"
  1324 + onPointerDown={(e) => beginPaperResize(e, 'bottom-right')}
  1325 + />
  1326 + </>
  1327 + )}
896 1328 {/* 纸张尺寸拖拽:底部拉高 */}
897 1329 {onTemplateChange && (
898 1330 <div
899 1331 className="absolute bottom-0 left-0 right-0 h-3 cursor-ns-resize flex items-center justify-center bg-gray-200/80 hover:bg-blue-400/30 border-t border-gray-300 text-[10px] text-gray-500 transition-colors"
900   - title="拖拽拉高纸张"
901   - onPointerDown={(e) => {
902   - e.stopPropagation();
903   - paperResizeRef.current = {
904   - edge: 'bottom',
905   - startX: e.clientX,
906   - startY: e.clientY,
907   - startW: template.width,
908   - startH: template.height,
909   - };
910   - (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
911   - }}
  1332 + title="Drag to resize paper (bottom edge)"
  1333 + data-paper-resize-handle="true"
  1334 + onPointerDown={(e) => beginPaperResize(e, 'bottom')}
912 1335 >
913 1336
914 1337 </div>
... ... @@ -917,18 +1340,9 @@ export function LabelCanvas({
917 1340 {onTemplateChange && (
918 1341 <div
919 1342 className="absolute top-0 right-0 bottom-0 w-3 cursor-ew-resize flex items-center justify-center bg-gray-200/80 hover:bg-blue-400/30 border-l border-gray-300 text-[10px] text-gray-500 transition-colors"
920   - title="拖拽拉宽纸张"
921   - onPointerDown={(e) => {
922   - e.stopPropagation();
923   - paperResizeRef.current = {
924   - edge: 'right',
925   - startX: e.clientX,
926   - startY: e.clientY,
927   - startW: template.width,
928   - startH: template.height,
929   - };
930   - (e.target as HTMLElement).setPointerCapture?.(e.pointerId);
931   - }}
  1343 + title="Drag to resize paper (right edge)"
  1344 + data-paper-resize-handle="true"
  1345 + onPointerDown={(e) => beginPaperResize(e, 'right')}
932 1346 >
933 1347
934 1348 </div>
... ... @@ -1057,12 +1471,21 @@ export function LabelPreviewOnly({
1057 1471 }) {
1058 1472 const baseW = unitToPx(template.width, template.unit);
1059 1473 const baseH = unitToPx(template.height, template.unit);
1060   - const scaleToFit = maxWidth ? Math.min(maxWidth / baseW, maxWidth / baseH, 2) : 1;
1061   - const displayW = baseW * scaleToFit;
1062   - const displayH = baseH * scaleToFit;
  1474 + const minX = Math.min(0, ...template.elements.map((el) => el.x));
  1475 + const minY = Math.min(0, ...template.elements.map((el) => el.y));
  1476 + const maxX = Math.max(baseW, ...template.elements.map((el) => el.x + el.width));
  1477 + const maxY = Math.max(baseH, ...template.elements.map((el) => el.y + el.height));
  1478 + const contentW = Math.max(1, maxX - minX);
  1479 + const contentH = Math.max(1, maxY - minY);
  1480 + const scaleToFit = maxWidth ? Math.min(maxWidth / contentW, maxWidth / contentH, 2) : 1;
  1481 + const displayW = contentW * scaleToFit;
  1482 + const displayH = contentH * scaleToFit;
1063 1483 // 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致
1064 1484 return (
1065   - <div className="flex items-center justify-center p-4 bg-gray-100 rounded">
  1485 + <div
  1486 + className="inline-flex items-center justify-center p-4 bg-gray-100 rounded"
  1487 + style={{ minWidth: displayW + 32 }}
  1488 + >
1066 1489 <div style={{ width: displayW, height: displayH }} className="relative bg-white shadow-lg overflow-hidden">
1067 1490 <div
1068 1491 className="origin-top-left"
... ... @@ -1070,8 +1493,8 @@ export function LabelPreviewOnly({
1070 1493 position: 'absolute',
1071 1494 left: 0,
1072 1495 top: 0,
1073   - width: baseW,
1074   - height: baseH,
  1496 + width: contentW,
  1497 + height: contentH,
1075 1498 transform: `scale(${scaleToFit})`,
1076 1499 transformOrigin: '0 0',
1077 1500 }}
... ... @@ -1083,8 +1506,8 @@ export function LabelPreviewOnly({
1083 1506 key={el.id}
1084 1507 className="absolute box-border overflow-hidden pointer-events-none flex items-center justify-center text-xs"
1085 1508 style={{
1086   - left: el.x,
1087   - top: el.y,
  1509 + left: el.x - minX,
  1510 + top: el.y - minY,
1088 1511 width: el.width,
1089 1512 height: el.height,
1090 1513 border: el.border === 'line' ? '1px solid #999' : el.border === 'dotted' ? '1px dotted #999' : undefined,
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
... ... @@ -17,12 +17,41 @@ import type {
17 17 Unit,
18 18 Rotation,
19 19 Border,
  20 + NutritionExtraItem,
20 21 } from '../../../types/labelTemplate';
  22 +import { canonicalElementType, isBlankSpaceElement, NUTRITION_FIXED_ITEMS } from '../../../types/labelTemplate';
21 23 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption';
22 24 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
23 25 import { Checkbox } from '../../ui/checkbox';
24 26 import { Trash2 } from 'lucide-react';
25 27  
  28 +const DATE_FORMAT_OPTIONS = [
  29 + 'DD/MM/YYYY',
  30 + 'MM/DD/YYYY',
  31 + 'DD/MM/YY',
  32 + 'MM/DD/YY',
  33 + 'MM/YY',
  34 + 'MM/DD',
  35 + 'MM',
  36 + 'DD',
  37 + 'YY',
  38 + 'FULLY DAY(WEDNESDAY)',
  39 + 'DAY (WED)',
  40 + 'MONTH (DECEMBER)',
  41 + 'YEAR (2025)',
  42 + 'DD MONTH YEAR (25 DECEMBER 2025)',
  43 +] as const;
  44 +const DATETIME_DEFAULT_FORMAT = 'YYYY-MM-DD HH:mm';
  45 +
  46 +const DURATION_FORMAT_OPTIONS = [
  47 + 'Minutes',
  48 + 'Hours',
  49 + 'Days',
  50 + 'Weeks',
  51 + 'Months (30 Day)',
  52 + 'Years',
  53 +] as const;
  54 +
26 55 interface PropertiesPanelProps {
27 56 template: LabelTemplate;
28 57 selectedElement: LabelElement | null;
... ... @@ -42,6 +71,7 @@ export function PropertiesPanel({
42 71 readOnlyTemplateCode = false,
43 72 }: PropertiesPanelProps) {
44 73 if (selectedElement) {
  74 + const isBlankElement = isBlankSpaceElement(selectedElement);
45 75 return (
46 76 <div className="w-72 shrink-0 border-l border-gray-200 bg-white flex flex-col h-full">
47 77 <div className="px-3 py-2 border-b border-gray-200 font-semibold text-gray-800">
... ... @@ -105,41 +135,45 @@ export function PropertiesPanel({
105 135 />
106 136 </div>
107 137 </div>
108   - <div>
109   - <Label className="text-xs">Rotation</Label>
110   - <Select
111   - value={selectedElement.rotation}
112   - onValueChange={(v: Rotation) =>
113   - onElementChange(selectedElement.id, { rotation: v })
114   - }
115   - >
116   - <SelectTrigger className="h-8 text-sm">
117   - <SelectValue />
118   - </SelectTrigger>
119   - <SelectContent>
120   - <SelectItem value="horizontal">horizontal</SelectItem>
121   - <SelectItem value="vertical">vertical</SelectItem>
122   - </SelectContent>
123   - </Select>
124   - </div>
125   - <div>
126   - <Label className="text-xs">Border</Label>
127   - <Select
128   - value={selectedElement.border}
129   - onValueChange={(v: Border) =>
130   - onElementChange(selectedElement.id, { border: v })
131   - }
132   - >
133   - <SelectTrigger className="h-8 text-sm">
134   - <SelectValue />
135   - </SelectTrigger>
136   - <SelectContent>
137   - <SelectItem value="none">none</SelectItem>
138   - <SelectItem value="line">line</SelectItem>
139   - <SelectItem value="dotted">dotted</SelectItem>
140   - </SelectContent>
141   - </Select>
142   - </div>
  138 + {!isBlankElement ? (
  139 + <div>
  140 + <Label className="text-xs">Rotation</Label>
  141 + <Select
  142 + value={selectedElement.rotation}
  143 + onValueChange={(v: Rotation) =>
  144 + onElementChange(selectedElement.id, { rotation: v })
  145 + }
  146 + >
  147 + <SelectTrigger className="h-8 text-sm">
  148 + <SelectValue />
  149 + </SelectTrigger>
  150 + <SelectContent>
  151 + <SelectItem value="horizontal">horizontal</SelectItem>
  152 + <SelectItem value="vertical">vertical</SelectItem>
  153 + </SelectContent>
  154 + </Select>
  155 + </div>
  156 + ) : null}
  157 + {!isBlankElement ? (
  158 + <div>
  159 + <Label className="text-xs">Border</Label>
  160 + <Select
  161 + value={selectedElement.border}
  162 + onValueChange={(v: Border) =>
  163 + onElementChange(selectedElement.id, { border: v })
  164 + }
  165 + >
  166 + <SelectTrigger className="h-8 text-sm">
  167 + <SelectValue />
  168 + </SelectTrigger>
  169 + <SelectContent>
  170 + <SelectItem value="none">none</SelectItem>
  171 + <SelectItem value="line">line</SelectItem>
  172 + <SelectItem value="dotted">dotted</SelectItem>
  173 + </SelectContent>
  174 + </Select>
  175 + </div>
  176 + ) : null}
143 177 <div>
144 178 <Label className="text-xs">Element name</Label>
145 179 <Input
... ... @@ -405,14 +439,6 @@ function TextStaticStyleFields({
405 439 />
406 440 </div>
407 441 <div>
408   - <Label className="text-xs">Prefix</Label>
409   - <Input
410   - value={(cfg.prefix as string) ?? '¥'}
411   - onChange={(e) => update('prefix', e.target.value)}
412   - className="h-8 text-sm mt-1"
413   - />
414   - </div>
415   - <div>
416 442 <Label className="text-xs">Font Size</Label>
417 443 <Input
418 444 type="number"
... ... @@ -460,6 +486,54 @@ function cfgPickNum(cfg: Record&lt;string, unknown&gt;, keys: string[], fallback: numb
460 486 return fallback;
461 487 }
462 488  
  489 +const WEIGHT_UNIT_OPTIONS: Array<{ value: string; label: string }> = [
  490 + { value: 'lb', label: 'Lb' },
  491 + { value: 'kg', label: 'Kg' },
  492 + { value: 'mg', label: 'Milligrams' },
  493 + { value: 'g', label: 'Grams' },
  494 + { value: 'oz', label: 'Ounces' },
  495 +];
  496 +
  497 +function normalizeWeightUnit(raw: unknown): string {
  498 + const unit = String(raw ?? '').trim().toLowerCase();
  499 + if (unit === 'milligrams') return 'mg';
  500 + if (unit === 'grams') return 'g';
  501 + if (unit === 'ounces') return 'oz';
  502 + if (unit === 'pounds') return 'lb';
  503 + if (unit === 'kilograms') return 'kg';
  504 + if (WEIGHT_UNIT_OPTIONS.some((item) => item.value === unit)) return unit;
  505 + return 'g';
  506 +}
  507 +
  508 +function nutritionExtraRows(cfg: Record<string, unknown>): NutritionExtraItem[] {
  509 + const raw = cfg.extraNutrients;
  510 + if (!Array.isArray(raw)) return [];
  511 + return raw.map((item, idx) => {
  512 + const row = item as Record<string, unknown>;
  513 + return {
  514 + id: String(row.id ?? `extra-${idx}`),
  515 + name: String(row.name ?? ''),
  516 + value: String(row.value ?? ''),
  517 + unit: String(row.unit ?? ''),
  518 + };
  519 + });
  520 +}
  521 +
  522 +function nutritionFixedField(
  523 + cfg: Record<string, unknown>,
  524 + key: string,
  525 + field: 'value' | 'unit',
  526 +): string {
  527 + const directKey = field === 'value' ? key : `${key}Unit`;
  528 + const direct = cfg[directKey];
  529 + if (direct != null && String(direct).trim() !== '') return String(direct).trim();
  530 + const fixedRows = Array.isArray(cfg.fixedNutrients)
  531 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  532 + : [];
  533 + const row = fixedRows.find((item) => String(item.key ?? '').trim() === key);
  534 + return String(row?.[field] ?? '').trim();
  535 +}
  536 +
463 537 function ElementConfigFields({
464 538 element,
465 539 onChange,
... ... @@ -468,10 +542,11 @@ function ElementConfigFields({
468 542 onChange: (config: Record<string, unknown>) => void;
469 543 }) {
470 544 const cfg = element.config as Record<string, unknown>;
  545 + const elementType = canonicalElementType(element.type);
471 546 const update = (key: string, value: unknown) =>
472 547 onChange({ [key]: value });
473 548  
474   - switch (element.type) {
  549 + switch (elementType) {
475 550 case 'TEXT_STATIC':
476 551 if (cfg.inputType === 'options') {
477 552 return (
... ... @@ -497,7 +572,7 @@ function ElementConfigFields({
497 572 />
498 573 </div>
499 574 <div>
500   - <Label className="text-xs">方向</Label>
  575 + <Label className="text-xs">Orientation</Label>
501 576 <Select
502 577 value={(cfg.orientation as string) ?? 'horizontal'}
503 578 onValueChange={(v) => update('orientation', v)}
... ... @@ -506,8 +581,8 @@ function ElementConfigFields({
506 581 <SelectValue />
507 582 </SelectTrigger>
508 583 <SelectContent>
509   - <SelectItem value="horizontal">水平</SelectItem>
510   - <SelectItem value="vertical">竖排</SelectItem>
  584 + <SelectItem value="horizontal">Horizontal</SelectItem>
  585 + <SelectItem value="vertical">Vertical</SelectItem>
511 586 </SelectContent>
512 587 </Select>
513 588 </div>
... ... @@ -564,16 +639,31 @@ function ElementConfigFields({
564 639 case 'DATE': {
565 640 const inputTypeNorm = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase();
566 641 const isPrintDate = inputTypeNorm === 'datetime' || inputTypeNorm === 'date';
  642 + const dateFormat = cfgPickStr(
  643 + cfg,
  644 + ['format', 'Format'],
  645 + inputTypeNorm === 'datetime' ? DATETIME_DEFAULT_FORMAT : 'DD/MM/YYYY',
  646 + );
  647 + const formatOptions =
  648 + inputTypeNorm === 'datetime'
  649 + ? [DATETIME_DEFAULT_FORMAT, ...DATE_FORMAT_OPTIONS]
  650 + : [...DATE_FORMAT_OPTIONS];
567 651 return (
568 652 <>
569 653 <div>
570 654 <Label className="text-xs">Format</Label>
571   - <Input
572   - value={cfgPickStr(cfg, ['format', 'Format'], 'YYYY-MM-DD')}
573   - onChange={(e) => update('format', e.target.value)}
574   - className="h-8 text-sm mt-1"
575   - placeholder="YYYY-MM-DD"
576   - />
  655 + <Select value={dateFormat} onValueChange={(v) => update('format', v)}>
  656 + <SelectTrigger className="h-8 text-sm mt-1">
  657 + <SelectValue />
  658 + </SelectTrigger>
  659 + <SelectContent>
  660 + {formatOptions.map((fmt) => (
  661 + <SelectItem key={fmt} value={fmt}>
  662 + {fmt}
  663 + </SelectItem>
  664 + ))}
  665 + </SelectContent>
  666 + </Select>
577 667 {isPrintDate ? (
578 668 <p className="text-[10px] text-gray-400 mt-1">
579 669 Shown as placeholder on the label until the app fills the date at print time.
... ... @@ -581,83 +671,177 @@ function ElementConfigFields({
581 671 ) : null}
582 672 </div>
583 673 <div>
584   - <Label className="text-xs">Offset Days</Label>
  674 + <Label className="text-xs">Font Size</Label>
585 675 <Input
586 676 type="number"
587   - value={cfgPickNum(cfg, ['offsetDays', 'OffsetDays'], 0)}
588   - onChange={(e) => update('offsetDays', Number(e.target.value) || 0)}
  677 + value={cfgPickNum(cfg, ['fontSize', 'FontSize'], 14)}
  678 + onChange={(e) => update('fontSize', Number(e.target.value) || 14)}
589 679 className="h-8 text-sm mt-1"
590 680 />
591 681 </div>
  682 + <div>
  683 + <Label className="text-xs">Text Align</Label>
  684 + <Select
  685 + value={cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left')}
  686 + onValueChange={(v) => update('textAlign', v)}
  687 + >
  688 + <SelectTrigger className="h-8 text-sm mt-1">
  689 + <SelectValue />
  690 + </SelectTrigger>
  691 + <SelectContent>
  692 + <SelectItem value="left">Left</SelectItem>
  693 + <SelectItem value="center">Center</SelectItem>
  694 + <SelectItem value="right">Right</SelectItem>
  695 + </SelectContent>
  696 + </Select>
  697 + </div>
592 698 </>
593 699 );
594 700 }
595 701 case 'TIME':
596 702 return (
597   - <div>
598   - <Label className="text-xs">Format</Label>
599   - <Input
600   - value={cfgPickStr(cfg, ['format', 'Format'], 'HH:mm')}
601   - onChange={(e) => update('format', e.target.value)}
602   - className="h-8 text-sm mt-1"
603   - placeholder="HH:mm"
604   - />
605   - </div>
606   - );
607   - case 'DURATION':
608   - return (
609 703 <>
610 704 <div>
611 705 <Label className="text-xs">Format</Label>
612   - <Input
613   - value={cfgPickStr(cfg, ['format', 'Format'], 'YYYY-MM-DD')}
614   - onChange={(e) => update('format', e.target.value)}
615   - className="h-8 text-sm mt-1"
616   - placeholder="YYYY-MM-DD"
617   - />
  706 + <Input value="HH:mm" className="h-8 text-sm mt-1" readOnly />
618 707 </div>
619 708 <div>
620   - <Label className="text-xs">Offset Days</Label>
  709 + <Label className="text-xs">Font Size</Label>
621 710 <Input
622 711 type="number"
623   - value={cfgPickNum(cfg, ['offsetDays', 'OffsetDays'], 3)}
624   - onChange={(e) => update('offsetDays', Number(e.target.value) || 3)}
  712 + value={cfgPickNum(cfg, ['fontSize', 'FontSize'], 14)}
  713 + onChange={(e) => update('fontSize', Number(e.target.value) || 14)}
625 714 className="h-8 text-sm mt-1"
626 715 />
627 716 </div>
  717 + <div>
  718 + <Label className="text-xs">Text Align</Label>
  719 + <Select
  720 + value={cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left')}
  721 + onValueChange={(v) => update('textAlign', v)}
  722 + >
  723 + <SelectTrigger className="h-8 text-sm mt-1">
  724 + <SelectValue />
  725 + </SelectTrigger>
  726 + <SelectContent>
  727 + <SelectItem value="left">Left</SelectItem>
  728 + <SelectItem value="center">Center</SelectItem>
  729 + <SelectItem value="right">Right</SelectItem>
  730 + </SelectContent>
  731 + </Select>
  732 + </div>
628 733 </>
629 734 );
630   - case 'WEIGHT':
  735 + case 'DURATION':
631 736 return (
632 737 <>
633 738 <div>
634   - <Label className="text-xs">Value</Label>
  739 + <Label className="text-xs">Format</Label>
  740 + <Select
  741 + value={cfgPickStr(cfg, ['format', 'Format'], 'Days')}
  742 + onValueChange={(v) => update('format', v)}
  743 + >
  744 + <SelectTrigger className="h-8 text-sm mt-1">
  745 + <SelectValue />
  746 + </SelectTrigger>
  747 + <SelectContent>
  748 + {DURATION_FORMAT_OPTIONS.map((fmt) => (
  749 + <SelectItem key={fmt} value={fmt}>
  750 + {fmt}
  751 + </SelectItem>
  752 + ))}
  753 + </SelectContent>
  754 + </Select>
  755 + </div>
  756 + <div>
  757 + <Label className="text-xs">Font Size</Label>
635 758 <Input
636 759 type="number"
637   - value={cfgPickNum(cfg, ['value', 'Value'], 500)}
638   - onChange={(e) => update('value', Number(e.target.value) || 0)}
  760 + value={cfgPickNum(cfg, ['fontSize', 'FontSize'], 14)}
  761 + onChange={(e) => update('fontSize', Number(e.target.value) || 14)}
639 762 className="h-8 text-sm mt-1"
640 763 />
641 764 </div>
642 765 <div>
643   - <Label className="text-xs">Unit</Label>
  766 + <Label className="text-xs">Text Align</Label>
644 767 <Select
645   - value={cfgPickStr(cfg, ['unit', 'Unit'], 'g')}
646   - onValueChange={(v) => update('unit', v)}
  768 + value={cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left')}
  769 + onValueChange={(v) => update('textAlign', v)}
647 770 >
648 771 <SelectTrigger className="h-8 text-sm mt-1">
649 772 <SelectValue />
650 773 </SelectTrigger>
651 774 <SelectContent>
652   - <SelectItem value="g">g</SelectItem>
653   - <SelectItem value="kg">kg</SelectItem>
654   - <SelectItem value="oz">oz</SelectItem>
655   - <SelectItem value="lb">lb</SelectItem>
  775 + <SelectItem value="left">Left</SelectItem>
  776 + <SelectItem value="center">Center</SelectItem>
  777 + <SelectItem value="right">Right</SelectItem>
656 778 </SelectContent>
657 779 </Select>
658 780 </div>
659 781 </>
660 782 );
  783 + case 'WEIGHT':
  784 + {
  785 + const weightUnit = normalizeWeightUnit(cfgPickStr(cfg, ['unit', 'Unit'], 'g'));
  786 + const textAlign = cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left');
  787 + const fontSize = cfgPickNum(cfg, ['fontSize', 'FontSize'], 14);
  788 + return (
  789 + <>
  790 + <div>
  791 + <Label className="text-xs">Value</Label>
  792 + <Input
  793 + type="number"
  794 + value={cfgPickNum(cfg, ['value', 'Value'], 500)}
  795 + onChange={(e) => update('value', Number(e.target.value) || 0)}
  796 + className="h-8 text-sm mt-1"
  797 + />
  798 + </div>
  799 + <div>
  800 + <Label className="text-xs">Unit</Label>
  801 + <Select
  802 + value={weightUnit}
  803 + onValueChange={(v) => update('unit', v)}
  804 + >
  805 + <SelectTrigger className="h-8 text-sm mt-1">
  806 + <SelectValue />
  807 + </SelectTrigger>
  808 + <SelectContent>
  809 + {WEIGHT_UNIT_OPTIONS.map((item) => (
  810 + <SelectItem key={item.value} value={item.value}>
  811 + {item.label}
  812 + </SelectItem>
  813 + ))}
  814 + </SelectContent>
  815 + </Select>
  816 + </div>
  817 + <div>
  818 + <Label className="text-xs">Font Size</Label>
  819 + <Input
  820 + type="number"
  821 + value={fontSize}
  822 + onChange={(e) => update('fontSize', Math.max(1, Number(e.target.value) || 14))}
  823 + className="h-8 text-sm mt-1"
  824 + />
  825 + </div>
  826 + <div>
  827 + <Label className="text-xs">Text Align</Label>
  828 + <Select
  829 + value={textAlign}
  830 + onValueChange={(v) => update('textAlign', v)}
  831 + >
  832 + <SelectTrigger className="h-8 text-sm mt-1">
  833 + <SelectValue />
  834 + </SelectTrigger>
  835 + <SelectContent>
  836 + <SelectItem value="left">Left</SelectItem>
  837 + <SelectItem value="center">Center</SelectItem>
  838 + <SelectItem value="right">Right</SelectItem>
  839 + </SelectContent>
  840 + </Select>
  841 + </div>
  842 + </>
  843 + );
  844 + }
661 845 case 'WEIGHT_PRICE':
662 846 return (
663 847 <>
... ... @@ -683,7 +867,7 @@ function ElementConfigFields({
683 867 <div>
684 868 <Label className="text-xs">Currency</Label>
685 869 <Input
686   - value={(cfg.currency as string) ?? ''}
  870 + value={(cfg.currency as string) ?? '$'}
687 871 onChange={(e) => update('currency', e.target.value)}
688 872 className="h-8 text-sm mt-1"
689 873 />
... ... @@ -691,43 +875,189 @@ function ElementConfigFields({
691 875 </>
692 876 );
693 877 case 'NUTRITION':
694   - return (
695   - <>
696   - <div>
697   - <Label className="text-xs">Calories</Label>
698   - <Input
699   - type="number"
700   - value={(cfg.calories as number) ?? 120}
701   - onChange={(e) => update('calories', Number(e.target.value) || 0)}
702   - className="h-8 text-sm mt-1"
703   - />
704   - </div>
705   - <div>
706   - <Label className="text-xs">Fat</Label>
707   - <Input
708   - value={(cfg.fat as string) ?? '5g'}
709   - onChange={(e) => update('fat', e.target.value)}
710   - className="h-8 text-sm mt-1"
711   - />
712   - </div>
713   - <div>
714   - <Label className="text-xs">Protein</Label>
715   - <Input
716   - value={(cfg.protein as string) ?? '3g'}
717   - onChange={(e) => update('protein', e.target.value)}
718   - className="h-8 text-sm mt-1"
719   - />
720   - </div>
721   - <div>
722   - <Label className="text-xs">Carbs</Label>
723   - <Input
724   - value={(cfg.carbs as string) ?? '10g'}
725   - onChange={(e) => update('carbs', e.target.value)}
726   - className="h-8 text-sm mt-1"
727   - />
728   - </div>
729   - </>
730   - );
  878 + {
  879 + const extraRows = nutritionExtraRows(cfg);
  880 + const applyFixedNutrients = (
  881 + key: string,
  882 + field: 'value' | 'unit',
  883 + nextValue: string,
  884 + ) => {
  885 + const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => {
  886 + const current = {
  887 + key: item.key,
  888 + label: item.label,
  889 + value: nutritionFixedField(cfg, item.key, 'value'),
  890 + unit: nutritionFixedField(cfg, item.key, 'unit'),
  891 + };
  892 + if (item.key !== key) return current;
  893 + return { ...current, [field]: nextValue };
  894 + });
  895 + const keyPatch: Record<string, unknown> = { fixedNutrients: fixedRows };
  896 + const target = fixedRows.find((item) => item.key === key);
  897 + if (target) {
  898 + keyPatch[key] = target.value;
  899 + keyPatch[`${key}Unit`] = target.unit;
  900 + }
  901 + onChange(keyPatch);
  902 + };
  903 +
  904 + const addExtraNutrient = () => {
  905 + const next: NutritionExtraItem[] = [
  906 + ...extraRows,
  907 + {
  908 + id: `extra-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
  909 + name: '',
  910 + value: '',
  911 + unit: '',
  912 + },
  913 + ];
  914 + update('extraNutrients', next);
  915 + };
  916 +
  917 + const updateExtraNutrient = (
  918 + id: string,
  919 + field: keyof NutritionExtraItem,
  920 + nextValue: string,
  921 + ) => {
  922 + const next = extraRows.map((item) =>
  923 + item.id === id ? { ...item, [field]: nextValue } : item,
  924 + );
  925 + update('extraNutrients', next);
  926 + };
  927 +
  928 + const removeExtraNutrient = (id: string) => {
  929 + update(
  930 + 'extraNutrients',
  931 + extraRows.filter((item) => item.id !== id),
  932 + );
  933 + };
  934 +
  935 + return (
  936 + <>
  937 + <div>
  938 + <Label className="text-xs">Nutrition summary</Label>
  939 + <div className="space-y-2 mt-1">
  940 + <div className="grid grid-cols-[1fr_90px] gap-2 items-center">
  941 + <span className="text-xs text-gray-600">Nutrition Facts title (px)</span>
  942 + <Input
  943 + type="number"
  944 + value={cfgPickNum(cfg, ['nutritionTitleFontSize', 'NutritionTitleFontSize'], 16)}
  945 + onChange={(e) =>
  946 + update('nutritionTitleFontSize', Math.max(10, Number(e.target.value) || 16))
  947 + }
  948 + className="h-8 text-sm"
  949 + />
  950 + </div>
  951 + <div className="grid grid-cols-[1fr_90px] gap-2 items-center">
  952 + <span className="text-xs text-gray-600">Servings Per Container</span>
  953 + <Input
  954 + value={cfgPickStr(cfg, ['servingsPerContainer', 'ServingsPerContainer'], '')}
  955 + onChange={(e) => update('servingsPerContainer', e.target.value)}
  956 + className="h-8 text-sm"
  957 + placeholder="e.g. 8"
  958 + />
  959 + </div>
  960 + <div className="grid grid-cols-[1fr_90px] gap-2 items-center">
  961 + <span className="text-xs text-gray-600">Serving Size</span>
  962 + <Input
  963 + value={cfgPickStr(cfg, ['servingSize', 'ServingSize'], '')}
  964 + onChange={(e) => update('servingSize', e.target.value)}
  965 + className="h-8 text-sm"
  966 + placeholder="e.g. 1 cup"
  967 + />
  968 + </div>
  969 + <div className="grid grid-cols-[1fr_90px] gap-2 items-center">
  970 + <span className="text-xs text-gray-600">Calories</span>
  971 + <Input
  972 + value={cfgPickStr(cfg, ['calories', 'Calories'], '')}
  973 + onChange={(e) => update('calories', e.target.value)}
  974 + className="h-8 text-sm"
  975 + placeholder="e.g. 120"
  976 + />
  977 + </div>
  978 + </div>
  979 + </div>
  980 + <div>
  981 + <div className="flex items-center justify-between mb-1">
  982 + <Label className="text-xs">Nutrition table</Label>
  983 + <Button type="button" variant="outline" className="h-7 px-2 text-xs" onClick={addExtraNutrient}>
  984 + Add nutrient
  985 + </Button>
  986 + </div>
  987 + <div className="space-y-1.5 mt-1">
  988 + <div className="grid grid-cols-[1fr_78px_58px_26px] gap-1.5 items-center text-[10px] text-gray-500 px-0.5">
  989 + <span>Name</span>
  990 + <span>Value</span>
  991 + <span>Unit</span>
  992 + <span />
  993 + </div>
  994 + {NUTRITION_FIXED_ITEMS.map((item) => (
  995 + <div key={item.key} className="grid grid-cols-[1fr_78px_58px_26px] gap-1.5 items-center">
  996 + <span className="text-xs text-gray-600">{item.label}</span>
  997 + <Input
  998 + value={nutritionFixedField(cfg, item.key, 'value')}
  999 + onChange={(e) =>
  1000 + applyFixedNutrients(item.key, 'value', e.target.value)
  1001 + }
  1002 + className="h-8 text-sm"
  1003 + placeholder="Value"
  1004 + />
  1005 + <Input
  1006 + value={nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '')}
  1007 + onChange={(e) =>
  1008 + applyFixedNutrients(item.key, 'unit', e.target.value)
  1009 + }
  1010 + className="h-8 text-sm"
  1011 + placeholder="Unit"
  1012 + />
  1013 + <span />
  1014 + </div>
  1015 + ))}
  1016 + </div>
  1017 + </div>
  1018 + <div className="space-y-2">
  1019 + {extraRows.length === 0 ? (
  1020 + <p className="text-[10px] text-gray-400">No custom nutrients yet.</p>
  1021 + ) : (
  1022 + extraRows.map((row) => (
  1023 + <div key={row.id} className="grid grid-cols-[1fr_78px_58px_26px] gap-1.5 items-center">
  1024 + <Input
  1025 + value={row.name}
  1026 + onChange={(e) => updateExtraNutrient(row.id, 'name', e.target.value)}
  1027 + className="h-8 text-sm"
  1028 + placeholder="Name"
  1029 + />
  1030 + <Input
  1031 + value={row.value}
  1032 + onChange={(e) => updateExtraNutrient(row.id, 'value', e.target.value)}
  1033 + className="h-8 text-sm"
  1034 + placeholder="Value"
  1035 + />
  1036 + <Input
  1037 + value={row.unit}
  1038 + onChange={(e) => updateExtraNutrient(row.id, 'unit', e.target.value)}
  1039 + className="h-8 text-sm"
  1040 + placeholder="Unit"
  1041 + />
  1042 + <Button
  1043 + type="button"
  1044 + variant="ghost"
  1045 + className="h-8 w-8 p-0 text-gray-500 hover:text-red-600"
  1046 + onClick={() => removeExtraNutrient(row.id)}
  1047 + aria-label="Delete nutrient"
  1048 + >
  1049 + <Trash2 className="h-3.5 w-3.5" />
  1050 + </Button>
  1051 + </div>
  1052 + ))
  1053 + )}
  1054 + <div className="text-[10px] text-gray-400">
  1055 + Unit is appended after value in template preview.
  1056 + </div>
  1057 + </div>
  1058 + </>
  1059 + );
  1060 + }
731 1061 case 'BLANK':
732 1062 return (
733 1063 <div className="text-xs text-gray-500">
... ... @@ -737,7 +1067,7 @@ function ElementConfigFields({
737 1067 default:
738 1068 return (
739 1069 <div className="text-xs text-gray-500">
740   - Config for {element.type} (edit in code if needed)
  1070 + Config for {elementType} (edit in code if needed)
741 1071 </div>
742 1072 );
743 1073 }
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
... ... @@ -10,12 +10,16 @@ import {
10 10 import type { ElementLibraryCategory, LabelTemplate, LabelElement } from '../../../types/labelTemplate';
11 11 import {
12 12 allocateElementName,
  13 + canonicalElementType,
  14 + composeElementTypeForPersist,
13 15 composeLibraryCategoryForPersist,
14 16 createDefaultTemplate,
15 17 createDefaultElement,
16 18 labelElementsToApiPayload,
17 19 resolvedLibraryCategoryForPersist,
  20 + resolvedElementTypeForPersist,
18 21 resolvedValueSourceTypeForSave,
  22 + stripLabelConfigPrefixes,
19 23 valueSourceTypeForLibraryCategory,
20 24 } from '../../../types/labelTemplate';
21 25 import { ElementsPanel } from './ElementsPanel';
... ... @@ -121,6 +125,8 @@ export function LabelTemplateEditor({
121 125 const vst = valueSourceTypeForLibraryCategory(libraryCategory);
122 126 el = {
123 127 ...el,
  128 + type: el.type,
  129 + typeAdd: composeElementTypeForPersist(libraryCategory, paletteItemLabel),
124 130 libraryCategory: composeLibraryCategoryForPersist(libraryCategory, paletteItemLabel),
125 131 valueSourceType: vst,
126 132 elementName,
... ... @@ -173,7 +179,7 @@ export function LabelTemplateEditor({
173 179 );
174 180 if (emptyName) {
175 181 toast.error("Component name required.", {
176   - description: "Each element must have a non-empty elementName (组件名字不能为空).",
  182 + description: "Each element must have a non-empty element name.",
177 183 });
178 184 return;
179 185 }
... ... @@ -237,9 +243,12 @@ export function LabelTemplateEditor({
237 243 ...template,
238 244 elements: template.elements.map((el) => ({
239 245 ...el,
  246 + type: canonicalElementType(el.type),
  247 + typeAdd: resolvedElementTypeForPersist(el),
240 248 elementName: (el.elementName ?? "").trim(),
241 249 valueSourceType: resolvedValueSourceTypeForSave(el),
242 250 libraryCategory: resolvedLibraryCategoryForPersist(el),
  251 + config: stripLabelConfigPrefixes((el.config ?? {}) as Record<string, unknown>),
243 252 })),
244 253 };
245 254 const blob = new Blob([JSON.stringify(payload, null, 2)], {
... ... @@ -294,11 +303,15 @@ export function LabelTemplateEditor({
294 303 onPreview={() => setPreviewOpen(true)}
295 304 />
296 305 <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
297   - <DialogContent className="max-w-[90vw] max-h-[90vh] overflow-auto">
298   - <DialogHeader>
299   - <DialogTitle>标签预览</DialogTitle>
  306 + <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 overflow-hidden flex flex-col">
  307 + <DialogHeader className="shrink-0 px-6 py-4 border-b bg-white">
  308 + <DialogTitle>Label preview</DialogTitle>
300 309 </DialogHeader>
301   - <LabelPreviewOnly template={template} maxWidth={500} />
  310 + <div className="flex-1 min-h-0 overflow-x-auto overflow-y-auto p-4 bg-gray-50">
  311 + <div className="min-w-max">
  312 + <LabelPreviewOnly template={template} maxWidth={0} />
  313 + </div>
  314 + </div>
302 315 </DialogContent>
303 316 </Dialog>
304 317 <PropertiesPanel
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
... ... @@ -24,7 +24,7 @@ import {
24 24 DialogHeader,
25 25 DialogTitle,
26 26 } from '../ui/dialog';
27   -import { Plus, Pencil, MoreHorizontal, Trash2, ClipboardList } from 'lucide-react';
  27 +import { Plus, Pencil, MoreHorizontal, Trash2 } from 'lucide-react';
28 28 import { toast } from 'sonner';
29 29 import { skipCountForPage } from '../../lib/paginationQuery';
30 30 import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
... ... @@ -40,7 +40,6 @@ import { getLabelTemplates, getLabelTemplate, deleteLabelTemplate } from &#39;../../
40 40 import { getLocations } from '../../services/locationService';
41 41 import { appliedLocationToEditor, type LabelTemplateDto } from '../../types/labelTemplate';
42 42 import { LabelTemplateEditor } from './LabelTemplateEditor';
43   -import { LabelTemplateDataEntryView } from './LabelTemplateDataEntryView';
44 43 import type { LabelElement, LabelTemplate } from '../../types/labelTemplate';
45 44 import type { LocationDto } from '../../types/location';
46 45  
... ... @@ -99,9 +98,8 @@ function templateListDisplaySize(t: LabelTemplateDto): string {
99 98  
100 99 export function LabelTemplatesView() {
101 100 const [templates, setTemplates] = useState<LabelTemplateDto[]>([]);
102   - const [viewMode, setViewMode] = useState<'list' | 'editor' | 'dataEntry'>('list');
  101 + const [viewMode, setViewMode] = useState<'list' | 'editor'>('list');
103 102 const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
104   - const [dataEntryTemplateCode, setDataEntryTemplateCode] = useState<string | null>(null);
105 103 const [initialTemplate, setInitialTemplate] = useState<LabelTemplate | null>(null);
106 104 const [loading, setLoading] = useState(false);
107 105 const [total, setTotal] = useState(0);
... ... @@ -209,7 +207,7 @@ export function LabelTemplatesView() {
209 207 // 转换 API 返回的 DTO 到编辑器需要的格式
210 208 const editorTemplate: LabelTemplate = {
211 209 id: apiTemplate.id,
212   - name: (apiTemplate.name ?? apiTemplate.templateName ?? '').trim() || '未命名模板',
  210 + name: (apiTemplate.name ?? apiTemplate.templateName ?? '').trim() || 'Unnamed template',
213 211 labelType: (apiTemplate.labelType as any) ?? 'PRICE',
214 212 unit: (apiTemplate.unit as any) ?? 'cm',
215 213 width: apiTemplate.width ?? 6,
... ... @@ -244,17 +242,6 @@ export function LabelTemplatesView() {
244 242 setInitialTemplate(null);
245 243 };
246 244  
247   - const handleOpenDataEntry = (templateCode: string) => {
248   - setActionsOpenForId(null);
249   - setDataEntryTemplateCode(templateCode);
250   - setViewMode('dataEntry');
251   - };
252   -
253   - const handleCloseDataEntry = () => {
254   - setViewMode('list');
255   - setDataEntryTemplateCode(null);
256   - };
257   -
258 245 const openDelete = (template: LabelTemplateDto) => {
259 246 setActionsOpenForId(null);
260 247 setDeletingTemplate(template);
... ... @@ -274,17 +261,6 @@ export function LabelTemplatesView() {
274 261 );
275 262 }
276 263  
277   - if (viewMode === 'dataEntry' && dataEntryTemplateCode) {
278   - return (
279   - <div className="h-[calc(100vh-8rem)] min-h-[500px] flex flex-col pt-2">
280   - <LabelTemplateDataEntryView
281   - templateCode={dataEntryTemplateCode}
282   - onBack={handleCloseDataEntry}
283   - />
284   - </div>
285   - );
286   - }
287   -
288 264 return (
289 265 <div className="h-full flex flex-col">
290 266 <div className="pb-4">
... ... @@ -404,17 +380,7 @@ export function LabelTemplatesView() {
404 380 <MoreHorizontal className="h-4 w-4 text-gray-500" />
405 381 </Button>
406 382 </PopoverTrigger>
407   - <PopoverContent align="end" className="w-48 p-1">
408   - <Button
409   - type="button"
410   - variant="ghost"
411   - className="w-full justify-start gap-2 h-9 px-2 font-normal"
412   - title="录入数据"
413   - onClick={() => handleOpenDataEntry(t.id)}
414   - >
415   - <ClipboardList className="w-4 h-4" />
416   - Enter Data
417   - </Button>
  383 + <PopoverContent align="end" className="w-40 p-1">
418 384 <Button
419 385 type="button"
420 386 variant="ghost"
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
... ... @@ -27,7 +27,7 @@ import {
27 27 import { Label } from "../ui/label";
28 28 import { Switch } from "../ui/switch";
29 29 import { Badge } from "../ui/badge";
30   -import { Plus, Edit, MoreHorizontal, ChevronsUpDown, Trash2 } from "lucide-react";
  30 +import { Plus, Edit, MoreHorizontal, ChevronsUpDown, Trash2, ClipboardList } from "lucide-react";
31 31 import { toast } from "sonner";
32 32 import { skipCountForPage } from "../../lib/paginationQuery";
33 33 import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
... ... @@ -61,6 +61,7 @@ import type { LabelCategoryDto } from &quot;../../types/labelCategory&quot;;
61 61 import type { LabelTypeDto } from "../../types/labelType";
62 62 import type { LabelTemplateDto } from "../../types/labelTemplate";
63 63 import type { ProductDto } from "../../types/product";
  64 +import { LabelTemplateDataEntryView } from "./LabelTemplateDataEntryView";
64 65  
65 66 function toDisplay(v: string | null | undefined): string {
66 67 const s = (v ?? "").trim();
... ... @@ -73,6 +74,15 @@ function labelRowCode(item: LabelDto): string {
73 74 return c || "None";
74 75 }
75 76  
  77 +/** 列表/详情里模板编码(列表接口曾只返 TemplateName;兼容 camelCase / PascalCase) */
  78 +function labelRowTemplateCode(item: LabelDto): string {
  79 + const raw = item as unknown as Record<string, unknown>;
  80 + const a = item.templateCode;
  81 + const b = raw.TemplateCode;
  82 + const s = (typeof a === "string" ? a : typeof b === "string" ? b : "") || "";
  83 + return s.trim();
  84 +}
  85 +
76 86 /** 列表行:产品列(接口可能返回 `products` 汇总字符串或 `productName` / productIds) */
77 87 function labelRowProductsText(item: LabelDto): string {
78 88 const aggregated = (item.products ?? "").trim();
... ... @@ -290,6 +300,8 @@ function ProductMultiSelectField({
290 300 }
291 301  
292 302 export function LabelsList() {
  303 + const [dataEntryTemplateCode, setDataEntryTemplateCode] = useState<string | null>(null);
  304 + const [dataEntryContextHint, setDataEntryContextHint] = useState<string | undefined>(undefined);
293 305 const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
294 306 const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
295 307 const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
... ... @@ -384,6 +396,60 @@ export function LabelsList() {
384 396 setIsDeleteDialogOpen(true);
385 397 };
386 398  
  399 + const openEnterDataForLabel = async (label: LabelDto) => {
  400 + setActionsOpenForId(null);
  401 + let code = labelRowTemplateCode(label);
  402 + if (!code) {
  403 + const id = labelRowCode(label);
  404 + if (!id || id === "None") {
  405 + toast.error("No template bound", {
  406 + description: "Missing label code. Edit this label and select a template before entering template data.",
  407 + });
  408 + return;
  409 + }
  410 + const loadingId = toast.loading("Loading label…");
  411 + try {
  412 + const detail = await getLabel(id);
  413 + code = labelRowTemplateCode(detail);
  414 + } catch (e: unknown) {
  415 + toast.dismiss(loadingId);
  416 + toast.error("Failed to load label", {
  417 + description: e instanceof Error ? e.message : "Please try again.",
  418 + });
  419 + return;
  420 + }
  421 + toast.dismiss(loadingId);
  422 + }
  423 + if (!code) {
  424 + toast.error("No template bound", {
  425 + description: "Edit this label and select a template before entering template data.",
  426 + });
  427 + return;
  428 + }
  429 + const name = (label.labelName ?? "").trim() || labelRowCode(label);
  430 + const lc = labelRowCode(label);
  431 + setDataEntryContextHint(`Bound label: ${name} (${lc}) — template defaults apply to this template.`);
  432 + setDataEntryTemplateCode(code);
  433 + };
  434 +
  435 + const closeDataEntry = () => {
  436 + setDataEntryTemplateCode(null);
  437 + setDataEntryContextHint(undefined);
  438 + refreshList();
  439 + };
  440 +
  441 + if (dataEntryTemplateCode) {
  442 + return (
  443 + <div className="h-[calc(100vh-8rem)] min-h-[500px] flex flex-col pt-2">
  444 + <LabelTemplateDataEntryView
  445 + templateCode={dataEntryTemplateCode}
  446 + onBack={closeDataEntry}
  447 + contextHint={dataEntryContextHint}
  448 + />
  449 + </div>
  450 + );
  451 + }
  452 +
387 453 return (
388 454 <div className="h-full flex flex-col">
389 455 <div className="pb-4">
... ... @@ -513,7 +579,17 @@ export function LabelsList() {
513 579 <MoreHorizontal className="h-4 w-4 text-gray-500" />
514 580 </Button>
515 581 </PopoverTrigger>
516   - <PopoverContent align="end" className="w-40 p-1">
  582 + <PopoverContent align="end" className="w-48 p-1">
  583 + <Button
  584 + type="button"
  585 + variant="ghost"
  586 + className="w-full justify-start gap-2 h-9 px-2 font-normal"
  587 + title="Add data to this label’s bound template"
  588 + onClick={() => openEnterDataForLabel(item)}
  589 + >
  590 + <ClipboardList className="w-4 h-4" />
  591 + Enter Data
  592 + </Button>
517 593 <Button
518 594 type="button"
519 595 variant="ghost"
... ...
美国版/Food Labeling Management Platform/src/components/layout/Sidebar.tsx
... ... @@ -46,7 +46,6 @@ export function Sidebar({ currentView, setCurrentView }: SidebarProps) {
46 46 },
47 47 { type: 'header', name: 'MANAGEMENT' },
48 48 { name: 'Location Manager', icon: MapPin, type: 'item' },
49   - { type: 'header', name: 'SYSTEM MANAGEMENT' },
50 49 { name: 'Account Management', icon: Users, type: 'item' },
51 50 { name: 'Menu Management', icon: Package, type: 'item' },
52 51 { name: 'System Menu', icon: Settings, type: 'item' },
... ...
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
... ... @@ -374,117 +374,120 @@ export function ProductsView() {
374 374 return (
375 375 <div className="h-full flex flex-col">
376 376 <div className="pb-4">
377   - <div className="flex flex-nowrap items-center gap-3 flex-wrap">
378   - <div
379   - className="flex items-center w-40 shrink-0 rounded-md border border-gray-300 bg-white overflow-hidden"
380   - style={{ height: 40 }}
381   - >
382   - <Search className="h-4 w-4 text-gray-400 shrink-0 ml-2.5 pointer-events-none" />
383   - <Input
384   - placeholder="Search..."
385   - value={keyword}
386   - onChange={(e) => setKeyword(e.target.value)}
387   - className="flex-1 min-w-0 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 py-2 px-2 h-full placeholder:text-gray-500"
388   - />
389   - </div>
390   - <Select value="all" disabled>
391   - <SelectTrigger
392   - className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0 opacity-70"
393   - style={{ height: 40, boxSizing: "border-box" }}
394   - >
395   - <SelectValue placeholder="Partner" />
396   - </SelectTrigger>
397   - <SelectContent>
398   - <SelectItem value="all">All partners</SelectItem>
399   - </SelectContent>
400   - </Select>
401   - <Select value="all" disabled>
402   - <SelectTrigger
403   - className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0 opacity-70"
404   - style={{ height: 40, boxSizing: "border-box" }}
  377 + <div className="flex flex-col gap-3">
  378 + <div className="flex flex-wrap items-center gap-3">
  379 + <div
  380 + className="flex items-center w-40 shrink-0 rounded-md border border-gray-300 bg-white overflow-hidden"
  381 + style={{ height: 40 }}
405 382 >
406   - <SelectValue placeholder="Group" />
407   - </SelectTrigger>
408   - <SelectContent>
409   - <SelectItem value="all">All groups</SelectItem>
410   - </SelectContent>
411   - </Select>
412   - <Select value={locationFilter} onValueChange={setLocationFilter}>
413   - <SelectTrigger
414   - className="w-[160px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0"
415   - style={{ height: 40, boxSizing: "border-box" }}
416   - >
417   - <SelectValue placeholder="Location" />
418   - </SelectTrigger>
419   - <SelectContent>
420   - <SelectItem value="all">All Locations</SelectItem>
421   - {locations.map((loc) => (
422   - <SelectItem key={loc.id} value={loc.id}>
423   - {toDisplay(loc.locationName ?? loc.locationCode ?? loc.id)}
424   - </SelectItem>
425   - ))}
426   - </SelectContent>
427   - </Select>
428   - <Select value={categoryFilter} onValueChange={setCategoryFilter}>
429   - <SelectTrigger
430   - className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0"
431   - style={{ height: 40, boxSizing: "border-box" }}
432   - >
433   - <SelectValue placeholder="Category" />
434   - </SelectTrigger>
435   - <SelectContent>
436   - <SelectItem value="all">All Categories</SelectItem>
437   - {categorySelectOptions.map((o) => (
438   - <SelectItem key={o.value} value={o.value}>
439   - {o.label}
440   - </SelectItem>
441   - ))}
442   - </SelectContent>
443   - </Select>
444   - <Select value={stateFilter} onValueChange={setStateFilter}>
445   - <SelectTrigger
446   - className="w-[120px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0"
447   - style={{ height: 40, boxSizing: "border-box" }}
448   - >
449   - <SelectValue placeholder="State" />
450   - </SelectTrigger>
451   - <SelectContent>
452   - <SelectItem value="all">All states</SelectItem>
453   - <SelectItem value="true">Active</SelectItem>
454   - <SelectItem value="false">Inactive</SelectItem>
455   - </SelectContent>
456   - </Select>
457   - <div className="flex-1 min-w-2" />
458   - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>
459   - <Upload className="w-4 h-4" /> Bulk Import
460   - </Button>
461   - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>
462   - <Download className="w-4 h-4" /> Bulk Export
463   - </Button>
464   - <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>
465   - <Edit className="w-4 h-4" /> Bulk Edit
466   - </Button>
467   - {activeTab === "products" ? (
468   - <Button
469   - className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"
470   - onClick={() => {
471   - setEditingProduct(null);
472   - setIsProductDialogOpen(true);
473   - }}
474   - >
475   - New Product <Plus className="w-4 h-4" />
  383 + <Search className="h-4 w-4 text-gray-400 shrink-0 ml-2.5 pointer-events-none" />
  384 + <Input
  385 + placeholder="Search..."
  386 + value={keyword}
  387 + onChange={(e) => setKeyword(e.target.value)}
  388 + className="flex-1 min-w-0 border-0 bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 py-2 px-2 h-full placeholder:text-gray-500"
  389 + />
  390 + </div>
  391 + <Select value="all" disabled>
  392 + <SelectTrigger
  393 + className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0 opacity-70"
  394 + style={{ height: 40, boxSizing: "border-box" }}
  395 + >
  396 + <SelectValue placeholder="Partner" />
  397 + </SelectTrigger>
  398 + <SelectContent>
  399 + <SelectItem value="all">All partners</SelectItem>
  400 + </SelectContent>
  401 + </Select>
  402 + <Select value="all" disabled>
  403 + <SelectTrigger
  404 + className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0 opacity-70"
  405 + style={{ height: 40, boxSizing: "border-box" }}
  406 + >
  407 + <SelectValue placeholder="Group" />
  408 + </SelectTrigger>
  409 + <SelectContent>
  410 + <SelectItem value="all">All groups</SelectItem>
  411 + </SelectContent>
  412 + </Select>
  413 + <Select value={locationFilter} onValueChange={setLocationFilter}>
  414 + <SelectTrigger
  415 + className="w-[160px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0"
  416 + style={{ height: 40, boxSizing: "border-box" }}
  417 + >
  418 + <SelectValue placeholder="Location" />
  419 + </SelectTrigger>
  420 + <SelectContent>
  421 + <SelectItem value="all">All Locations</SelectItem>
  422 + {locations.map((loc) => (
  423 + <SelectItem key={loc.id} value={loc.id}>
  424 + {toDisplay(loc.locationName ?? loc.locationCode ?? loc.id)}
  425 + </SelectItem>
  426 + ))}
  427 + </SelectContent>
  428 + </Select>
  429 + <Select value={categoryFilter} onValueChange={setCategoryFilter}>
  430 + <SelectTrigger
  431 + className="w-[140px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0"
  432 + style={{ height: 40, boxSizing: "border-box" }}
  433 + >
  434 + <SelectValue placeholder="Category" />
  435 + </SelectTrigger>
  436 + <SelectContent>
  437 + <SelectItem value="all">All Categories</SelectItem>
  438 + {categorySelectOptions.map((o) => (
  439 + <SelectItem key={o.value} value={o.value}>
  440 + {o.label}
  441 + </SelectItem>
  442 + ))}
  443 + </SelectContent>
  444 + </Select>
  445 + <Select value={stateFilter} onValueChange={setStateFilter}>
  446 + <SelectTrigger
  447 + className="w-[120px] h-10 rounded-md border border-gray-300 bg-white font-medium text-gray-900 shrink-0"
  448 + style={{ height: 40, boxSizing: "border-box" }}
  449 + >
  450 + <SelectValue placeholder="State" />
  451 + </SelectTrigger>
  452 + <SelectContent>
  453 + <SelectItem value="all">All states</SelectItem>
  454 + <SelectItem value="true">Active</SelectItem>
  455 + <SelectItem value="false">Inactive</SelectItem>
  456 + </SelectContent>
  457 + </Select>
  458 + </div>
  459 + <div className="flex flex-nowrap items-center justify-end gap-3 min-w-0 overflow-x-auto pb-0.5 [scrollbar-width:thin]">
  460 + <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>
  461 + <Upload className="w-4 h-4" /> Bulk Import
476 462 </Button>
477   - ) : (
478   - <Button
479   - className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"
480   - onClick={() => {
481   - setEditingProductCategory(null);
482   - setIsProductCategoryDialogOpen(true);
483   - }}
484   - >
485   - New Category <Plus className="w-4 h-4" />
  463 + <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>
  464 + <Download className="w-4 h-4" /> Bulk Export
486 465 </Button>
487   - )}
  466 + <Button variant="outline" className="h-10 border border-gray-300 rounded-md text-gray-900 bg-white hover:bg-gray-50 gap-2 shrink-0" disabled>
  467 + <Edit className="w-4 h-4" /> Bulk Edit
  468 + </Button>
  469 + {activeTab === "products" ? (
  470 + <Button
  471 + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"
  472 + onClick={() => {
  473 + setEditingProduct(null);
  474 + setIsProductDialogOpen(true);
  475 + }}
  476 + >
  477 + New Product <Plus className="w-4 h-4" />
  478 + </Button>
  479 + ) : (
  480 + <Button
  481 + className="h-10 rounded-md bg-blue-600 text-white hover:bg-blue-700 font-medium gap-1 shrink-0"
  482 + onClick={() => {
  483 + setEditingProductCategory(null);
  484 + setIsProductCategoryDialogOpen(true);
  485 + }}
  486 + >
  487 + New Category <Plus className="w-4 h-4" />
  488 + </Button>
  489 + )}
  490 + </div>
488 491 </div>
489 492  
490 493 <div className="w-full border-b border-gray-200 mt-4">
... ...
美国版/Food Labeling Management Platform/src/services/labelTemplateService.ts
1 1 import { createApiClient } from "../lib/apiClient";
2   -import type {
3   - LabelElement,
4   - LabelTemplateCreateInput,
5   - LabelTemplateDto,
6   - LabelTemplateGetListInput,
7   - LabelTemplateProductDefaultDto,
8   - LabelTemplateUpdateInput,
9   - PagedResultDto,
  2 +import {
  3 + stripLabelConfigPrefixes,
  4 + type LabelElement,
  5 + type LabelTemplateCreateInput,
  6 + type LabelTemplateDto,
  7 + type LabelTemplateGetListInput,
  8 + type LabelTemplateProductDefaultDto,
  9 + type LabelTemplateUpdateInput,
  10 + type PagedResultDto,
10 11 } from "../types/labelTemplate";
11 12  
12 13 const api = createApiClient({
... ... @@ -37,24 +38,32 @@ function normalizeTemplateElements(list: unknown): LabelElement[] {
37 38 inputKey?: unknown;
38 39 ElementName?: unknown;
39 40 elementName?: unknown;
  41 + TypeAdd?: unknown;
  42 + typeAdd?: unknown;
40 43 LibraryCategory?: unknown;
41 44 libraryCategory?: unknown;
42 45 };
43 46 const ik = e.inputKey ?? e.InputKey;
44 47 const nameRaw = e.elementName ?? e.ElementName;
  48 + const typeAddRaw = e.typeAdd ?? e.TypeAdd;
45 49 const lcRaw = e.libraryCategory ?? e.LibraryCategory;
46 50 let libraryCategory: LabelElement["libraryCategory"];
47 51 if (typeof lcRaw === "string") {
48 52 const t = lcRaw.trim();
49 53 if (t) libraryCategory = t;
50 54 }
  55 + const rawCfg =
  56 + e.config && typeof e.config === "object" && !Array.isArray(e.config)
  57 + ? (e.config as Record<string, unknown>)
  58 + : {};
51 59 return {
52 60 ...(e as object),
53 61 elementName:
54 62 typeof nameRaw === "string" ? nameRaw.trim() : undefined,
  63 + typeAdd: typeof typeAddRaw === "string" ? typeAddRaw.trim() : undefined,
55 64 inputKey: typeof ik === "string" ? ik : e.inputKey ?? null,
56 65 libraryCategory,
57   - config: (e.config && typeof e.config === "object" ? e.config : {}) as LabelElement["config"],
  66 + config: stripLabelConfigPrefixes(rawCfg) as LabelElement["config"],
58 67 } as LabelElement;
59 68 });
60 69 }
... ...
美国版/Food Labeling Management Platform/src/types/labelTemplate.ts
... ... @@ -23,31 +23,80 @@ export type ElementType =
23 23 | 'WEIGHT_PRICE'
24 24 | 'BLANK'
25 25 | 'NUTRITION';
  26 +export type ElementTypeValue = ElementType | string;
26 27  
27   -/** 左侧元素库分组标题(与 Elements 面板四类一致;导出/保存时写入每条 element) */
  28 +export interface NutritionFixedItem {
  29 + key: string;
  30 + label: string;
  31 + defaultUnit?: string;
  32 +}
  33 +
  34 +export interface NutritionExtraItem {
  35 + id: string;
  36 + name: string;
  37 + value: string;
  38 + unit: string;
  39 +}
  40 +
  41 +export const NUTRITION_FIXED_ITEMS: readonly NutritionFixedItem[] = [
  42 + { key: 'fat', label: 'Total Fat', defaultUnit: 'g' },
  43 + { key: 'saturatedFat', label: 'Saturated Fat', defaultUnit: 'g' },
  44 + { key: 'transFat', label: 'Trans Fat', defaultUnit: 'g' },
  45 + { key: 'cholesterol', label: 'Cholesterol', defaultUnit: 'mg' },
  46 + { key: 'sodium', label: 'Sodium', defaultUnit: 'mg' },
  47 + { key: 'carbs', label: 'Total Carbohydrates', defaultUnit: 'g' },
  48 + { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g' },
  49 + { key: 'totalSugar', label: 'Total Sugar', defaultUnit: 'g' },
  50 + { key: 'protein', label: 'Protein', defaultUnit: 'g' },
  51 + { key: 'vitaminA', label: 'Vitamin A', defaultUnit: 'mcg' },
  52 + { key: 'vitaminC', label: 'Vitamin C', defaultUnit: 'mg' },
  53 + { key: 'calcium', label: 'Calcium', defaultUnit: 'mg' },
  54 + { key: 'iron', label: 'Iron', defaultUnit: 'mg' },
  55 +] as const;
  56 +
  57 +/** Left panel section titles (Elements panel); persisted per element as part of libraryCategory logic */
28 58 export type ElementLibraryCategory =
29   - | '模版信息'
30   - | '标签信息'
31   - | '自动生成'
32   - | '打印时输入';
  59 + | 'Template'
  60 + | 'Label'
  61 + | 'Auto-generated'
  62 + | 'Print input';
33 63  
34 64 export const ELEMENT_LIBRARY_CATEGORIES: readonly ElementLibraryCategory[] = [
35   - '模版信息',
36   - '标签信息',
37   - '自动生成',
38   - '打印时输入',
  65 + 'Template',
  66 + 'Label',
  67 + 'Auto-generated',
  68 + 'Print input',
39 69 ] as const;
40 70  
41   -export function isElementLibraryCategory(s: string): s is ElementLibraryCategory {
42   - return (ELEMENT_LIBRARY_CATEGORIES as readonly string[]).includes(s);
  71 +/** Legacy Chinese group titles from older templates / JSON */
  72 +export const LEGACY_ELEMENT_LIBRARY_CATEGORY_TO_ENGLISH: Record<string, ElementLibraryCategory> = {
  73 + 模版信息: 'Template',
  74 + 模板信息: 'Template',
  75 + 标签信息: 'Label',
  76 + 自动生成: 'Auto-generated',
  77 + 打印时输入: 'Print input',
  78 +};
  79 +
  80 +export function toCanonicalElementLibraryCategory(raw: string): ElementLibraryCategory {
  81 + const t = String(raw ?? '').trim();
  82 + if ((ELEMENT_LIBRARY_CATEGORIES as readonly string[]).includes(t)) {
  83 + return t as ElementLibraryCategory;
  84 + }
  85 + return LEGACY_ELEMENT_LIBRARY_CATEGORY_TO_ENGLISH[t] ?? 'Template';
43 86 }
44 87  
45   -/** 左侧面板分组 → 接口 ValueSourceType(模板+标签信息=FIXED;自动生成=AUTO_DB;打印时输入=PRINT_INPUT) */
  88 +export function isElementLibraryCategory(s: string): boolean {
  89 + const t = String(s ?? '').trim();
  90 + return (ELEMENT_LIBRARY_CATEGORIES as readonly string[]).includes(t) || t in LEGACY_ELEMENT_LIBRARY_CATEGORY_TO_ENGLISH;
  91 +}
  92 +
  93 +/** Panel group → API ValueSourceType (Template+Label=FIXED; Auto-generated=AUTO_DB; Print input=PRINT_INPUT) */
46 94 export function valueSourceTypeForLibraryCategory(
47   - group: ElementLibraryCategory,
  95 + group: ElementLibraryCategory | string,
48 96 ): "FIXED" | "AUTO_DB" | "PRINT_INPUT" {
49   - if (group === "自动生成") return "AUTO_DB";
50   - if (group === "打印时输入") return "PRINT_INPUT";
  97 + const g = toCanonicalElementLibraryCategory(String(group));
  98 + if (g === "Auto-generated") return "AUTO_DB";
  99 + if (g === "Print input") return "PRINT_INPUT";
51 100 return "FIXED";
52 101 }
53 102  
... ... @@ -81,12 +130,12 @@ export function allocateElementName(
81 130 return `${base}${next}`;
82 131 }
83 132  
84   -/** 四类分组 → 导出/保存用的英文前缀(与 JSON 中 libraryCategory 第一段一致) */
  133 +/** Group → persisted libraryCategory prefix (first segment in JSON) */
85 134 export const LIBRARY_CATEGORY_ENGLISH_PREFIX: Record<ElementLibraryCategory, string> = {
86   - 模版信息: "template",
87   - 标签信息: "label",
88   - 自动生成: "auto",
89   - 打印时输入: "print",
  135 + Template: "template",
  136 + Label: "label",
  137 + "Auto-generated": "auto",
  138 + "Print input": "print",
90 139 };
91 140  
92 141 /** 形如 `print_Multiple Options`、`template_Text` */
... ... @@ -110,17 +159,18 @@ export function isComposedLibraryCategoryValue(s: string): boolean {
110 159 */
111 160 export function inferPaletteEnglishLabel(el: LabelElement): string {
112 161 const cfg = (el.config ?? {}) as Record<string, unknown>;
  162 + const type = canonicalElementType(el.type);
113 163 if (isPrintInputElement(el)) {
114   - if (el.type === "TEXT_STATIC") {
  164 + if (type === "TEXT_STATIC") {
115 165 const it = cfg.inputType as string | undefined;
116 166 if (it === "number") return "Number";
117 167 if (it === "options") return "Multiple Options";
118 168 return "Text";
119 169 }
120   - if (el.type === "DATE") return "Date & Time";
121   - if (el.type === "WEIGHT") return "Weight";
  170 + if (type === "DATE") return "Date & Time";
  171 + if (type === "WEIGHT") return "Weight";
122 172 }
123   - switch (el.type) {
  173 + switch (type) {
124 174 case "TEXT_PRODUCT":
125 175 return "Label Name";
126 176 case "TEXT_PRICE":
... ... @@ -146,7 +196,7 @@ export function inferPaletteEnglishLabel(el: LabelElement): string {
146 196 case "TEXT_STATIC":
147 197 return "Text";
148 198 default:
149   - return el.type.replace(/_/g, " ");
  199 + return type.replace(/_/g, " ");
150 200 }
151 201 }
152 202  
... ... @@ -170,7 +220,9 @@ export interface LabelElement {
170 220 id: string;
171 221 /** 组件名,接口必填;录入表表头展示 */
172 222 elementName?: string | null;
173   - type: ElementType;
  223 + type: ElementTypeValue;
  224 + /** 按“分组前缀_控件名”持久化,如 label_Duration */
  225 + typeAdd?: string | null;
174 226 x: number;
175 227 y: number;
176 228 width: number;
... ... @@ -192,21 +244,21 @@ export interface LabelElement {
192 244 config: Record<string, unknown>;
193 245 }
194 246  
195   -/** 元素类型与 UI 名称(左侧标签库) */
  247 +/** Element type labels (palette / tooling) */
196 248 export const ELEMENT_TYPE_LABELS: { type: ElementType; label: string }[] = [
197   - { type: 'TEXT_STATIC', label: '普通文本' },
198   - { type: 'TEXT_PRODUCT', label: '商品名称' },
199   - { type: 'TEXT_PRICE', label: '商品价格' },
200   - { type: 'BARCODE', label: '一维条码' },
201   - { type: 'QRCODE', label: '二维码' },
202   - { type: 'IMAGE', label: '图片 / Logo' },
203   - { type: 'DATE', label: '当前日期' },
204   - { type: 'TIME', label: '当前时间' },
205   - { type: 'DURATION', label: '保质期 / 有效期' },
206   - { type: 'WEIGHT', label: '重量' },
207   - { type: 'WEIGHT_PRICE', label: '按重量计价' },
208   - { type: 'BLANK', label: '空白占位' },
209   - { type: 'NUTRITION', label: '营养成分表' },
  249 + { type: 'TEXT_STATIC', label: 'Plain text' },
  250 + { type: 'TEXT_PRODUCT', label: 'Product name' },
  251 + { type: 'TEXT_PRICE', label: 'Price' },
  252 + { type: 'BARCODE', label: 'Barcode' },
  253 + { type: 'QRCODE', label: 'QR code' },
  254 + { type: 'IMAGE', label: 'Image / Logo' },
  255 + { type: 'DATE', label: 'Current date' },
  256 + { type: 'TIME', label: 'Current time' },
  257 + { type: 'DURATION', label: 'Shelf life / expiry' },
  258 + { type: 'WEIGHT', label: 'Weight' },
  259 + { type: 'WEIGHT_PRICE', label: 'Price by weight' },
  260 + { type: 'BLANK', label: 'Blank space' },
  261 + { type: 'NUTRITION', label: 'Nutrition facts' },
210 262 ];
211 263  
212 264 const STORAGE_KEY_PREFIX = 'label-template-';
... ... @@ -234,7 +286,7 @@ export function generateElementId(): string {
234 286 export function createDefaultTemplate(id?: string): LabelTemplate {
235 287 return {
236 288 id: id ?? generateTemplateId(),
237   - name: '未命名模板',
  289 + name: 'Unnamed template',
238 290 labelType: 'PRICE',
239 291 unit: 'cm',
240 292 width: 6,
... ... @@ -265,19 +317,54 @@ export const PRESET_LABEL_SIZES: { name: string; width: number; height: number;
265 317 export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelElement {
266 318 const id = generateElementId();
267 319 const defaults: Record<ElementType, { width: number; height: number; config: Record<string, unknown> }> = {
268   - TEXT_STATIC: { width: 120, height: 24, config: { text: '文本', fontFamily: 'Arial', fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
269   - TEXT_PRODUCT: { width: 120, height: 24, config: { text: '商品名', fontFamily: 'Arial', fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
270   - TEXT_PRICE: { width: 80, height: 24, config: { text: '0.00', prefix: '¥', decimal: 2, fontFamily: 'Arial', fontSize: 14, fontWeight: 'bold', textAlign: 'right' } },
  320 + TEXT_STATIC: { width: 120, height: 24, config: { text: 'Text', fontFamily: 'Arial', fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
  321 + TEXT_PRODUCT: { width: 120, height: 24, config: { text: 'Product name', fontFamily: 'Arial', fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
  322 + TEXT_PRICE: { width: 80, height: 24, config: { text: '0.00', decimal: 2, fontFamily: 'Arial', fontSize: 14, fontWeight: 'bold', textAlign: 'right' } },
271 323 BARCODE: { width: 160, height: 48, config: { barcodeType: 'CODE128', data: '123456789', showText: true, orientation: 'horizontal' } },
272 324 QRCODE: { width: 80, height: 80, config: { data: 'https://example.com', errorLevel: 'M' } },
273 325 IMAGE: { width: 60, height: 60, config: { src: '', scaleMode: 'contain' } },
274   - DATE: { width: 120, height: 24, config: { format: 'YYYY-MM-DD', offsetDays: 0 } },
  326 + DATE: {
  327 + width: 120,
  328 + height: 24,
  329 + config: { format: 'DD/MM/YYYY', offsetDays: 0, fontSize: 14, textAlign: 'left' },
  330 + },
275 331 TIME: { width: 100, height: 24, config: { format: 'HH:mm', offsetDays: 0 } },
276   - DURATION: { width: 120, height: 24, config: { format: 'YYYY-MM-DD', offsetDays: 3 } },
277   - WEIGHT: { width: 80, height: 24, config: { unit: 'g', value: 500 } },
278   - WEIGHT_PRICE: { width: 100, height: 24, config: { unitPrice: 10, weight: 0.5, currency: '¥' } },
  332 + DURATION: {
  333 + width: 120,
  334 + height: 24,
  335 + config: { format: 'Days', durationValue: 3, fontSize: 14, textAlign: 'left' },
  336 + },
  337 + WEIGHT: { width: 80, height: 24, config: { unit: 'g', value: 500, fontSize: 14, textAlign: 'left' } },
  338 + WEIGHT_PRICE: { width: 100, height: 24, config: { unitPrice: 10, weight: 0.5, currency: '$' } },
279 339 BLANK: { width: 40, height: 24, config: {} },
280   - NUTRITION: { width: 200, height: 120, config: { calories: 120, fat: '5g', protein: '3g', carbs: '10g', layout: 'standard' } },
  340 + NUTRITION: {
  341 + width: 200,
  342 + height: 120,
  343 + config: {
  344 + nutritionTitleFontSize: 16,
  345 + servingsPerContainer: '',
  346 + servingSize: '',
  347 + calories: '120',
  348 + fat: '5',
  349 + protein: '3',
  350 + carbs: '10',
  351 + layout: 'standard',
  352 + fixedNutrients: NUTRITION_FIXED_ITEMS.map((item) => ({
  353 + key: item.key,
  354 + label: item.label,
  355 + value:
  356 + item.key === 'fat'
  357 + ? '5'
  358 + : item.key === 'protein'
  359 + ? '3'
  360 + : item.key === 'carbs'
  361 + ? '10'
  362 + : '',
  363 + unit: item.defaultUnit ?? '',
  364 + })),
  365 + extraNutrients: [],
  366 + },
  367 + },
281 368 };
282 369 const d = defaults[type];
283 370 return {
... ... @@ -353,7 +440,8 @@ export type LabelTemplateGetListInput = {
353 440 export type LabelTemplateApiElement = {
354 441 id: string;
355 442 elementName: string;
356   - type: ElementType;
  443 + type: string;
  444 + typeAdd?: string;
357 445 x: number;
358 446 y: number;
359 447 width: number;
... ... @@ -388,11 +476,25 @@ export type LabelTemplateCreateInput = {
388 476  
389 477 export type LabelTemplateUpdateInput = LabelTemplateCreateInput;
390 478  
  479 +/** 平台侧不再使用 config.prefix / Prefix(价格符号、多选项前缀等一律去掉) */
  480 +export function stripLabelConfigPrefixes(
  481 + config: Record<string, unknown> | null | undefined,
  482 +): Record<string, unknown> {
  483 + const c =
  484 + config && typeof config === "object" && !Array.isArray(config)
  485 + ? { ...config }
  486 + : {};
  487 + delete c.prefix;
  488 + delete c.Prefix;
  489 + return c;
  490 +}
  491 +
391 492 export function labelElementsToApiPayload(elements: LabelElement[]): LabelTemplateApiElement[] {
392 493 return elements.map((el, index) => ({
393 494 id: el.id,
394 495 elementName: (el.elementName ?? "").trim(),
395   - type: el.type,
  496 + type: canonicalElementType(el.type),
  497 + typeAdd: resolvedElementTypeForPersist(el),
396 498 x: el.x,
397 499 y: el.y,
398 500 width: el.width,
... ... @@ -406,7 +508,7 @@ export function labelElementsToApiPayload(elements: LabelElement[]): LabelTempla
406 508 ? { inputKey: String(el.inputKey).trim() }
407 509 : {}),
408 510 isRequiredInput: el.isRequiredInput ?? false,
409   - config: (el.config ?? {}) as Record<string, unknown>,
  511 + config: stripLabelConfigPrefixes((el.config ?? {}) as Record<string, unknown>),
410 512 }));
411 513 }
412 514  
... ... @@ -436,22 +538,24 @@ export function defaultValueSourceTypeForElement(type: ElementType): string {
436 538  
437 539 /** 画布「打印时输入」区拖入的元素,或与后端约定 valueSourceType=PRINT_INPUT */
438 540 export function isPrintInputElement(el: LabelElement): boolean {
  541 + const type = canonicalElementType(el.type);
439 542 const vst = String(el.valueSourceType ?? "").trim().toUpperCase();
440 543 if (vst === "PRINT_INPUT") return true;
441 544 const cfg = (el.config ?? {}) as Record<string, unknown>;
442   - if (el.type === "TEXT_STATIC" && cfg.inputType != null && String(cfg.inputType).trim() !== "") {
  545 + if (type === "TEXT_STATIC" && cfg.inputType != null && String(cfg.inputType).trim() !== "") {
443 546 return true;
444 547 }
445   - if (el.type === "DATE" && (cfg.inputType === "datetime" || cfg.inputType === "date")) {
  548 + if (type === "DATE" && (cfg.inputType === "datetime" || cfg.inputType === "date")) {
446 549 return true;
447 550 }
448   - if (el.type === "WEIGHT") return true;
  551 + if (type === "WEIGHT") return true;
449 552 return false;
450 553 }
451 554  
452 555 /** 录入数据表列标题 */
453 556 export function printInputFieldLabel(el: LabelElement): string {
454 557 const cfg = (el.config ?? {}) as Record<string, unknown>;
  558 + const type = canonicalElementType(el.type);
455 559 const t = typeof cfg.text === "string" ? cfg.text.trim() : "";
456 560 if (t) return t;
457 561 const it = cfg.inputType as string | undefined;
... ... @@ -459,8 +563,8 @@ export function printInputFieldLabel(el: LabelElement): string {
459 563 if (it === "text") return "Text";
460 564 if (it === "options") return "Multiple Options";
461 565 if (it === "datetime" || it === "date") return "Date & Time";
462   - if (el.type === "WEIGHT") return "Weight";
463   - return el.type.replace(/_/g, " ");
  566 + if (type === "WEIGHT") return "Weight";
  567 + return type.replace(/_/g, " ");
464 568 }
465 569  
466 570 /** 接口里 inputKey 可能是 InputKey */
... ... @@ -470,32 +574,65 @@ export function elementInputKey(el: LabelElement): string {
470 574 return typeof k === "string" ? k.trim() : "";
471 575 }
472 576  
  577 +/** 占位组件(Blank Space) */
  578 +export function isBlankSpaceElement(el: LabelElement): boolean {
  579 + return canonicalElementType(el.type) === "BLANK";
  580 +}
  581 +
473 582 /**
474   - * 录入数据表格:仅 ValueSourceType=FIXED 的列可填;AUTO_DB / PRINT_INPUT 在 App 解析或打印时处理。
  583 + * 录入数据表格:仅保留需要手动录入的控件。
  584 + * - PRINT_INPUT(打印时输入)默认保留
  585 + * - Duration 系列(Duration Date / Duration Time / Duration)也视为手动录入
  586 + * - 明确排除:BLANK / BARCODE / NUTRITION
475 587 */
476 588 export function isDataEntryTableColumnElement(el: LabelElement): boolean {
  589 + const persistedType = String(
  590 + el.typeAdd ??
  591 + (COMPOSED_ELEMENT_TYPE_RE.test(String(el.type ?? "").trim())
  592 + ? String(el.type ?? "").trim()
  593 + : resolvedElementTypeForPersist(el))
  594 + )
  595 + .trim()
  596 + .toLowerCase();
  597 + const manualTypeAddWhitelist = new Set([
  598 + "template_text",
  599 + "template_price",
  600 + "template_logo",
  601 + "template_image",
  602 + "label_label name",
  603 + "label_text",
  604 + "label_price",
  605 + "label_duration date",
  606 + "label_duration time",
  607 + "label_duration",
  608 + "label_label type",
  609 + "label_expiration alert",
  610 + ]);
  611 + const type = canonicalElementType(el.type);
477 612 const vst = normalizeValueSourceTypeForElement(el);
478   - if (vst !== "FIXED") return false;
479   - if (el.type === "BLANK") return false;
480   - return true;
  613 + if (isBlankSpaceElement(el)) return false;
  614 + if (type === "BARCODE") return false;
  615 + if (type === "NUTRITION") return false;
  616 + return vst === "FIXED" && manualTypeAddWhitelist.has(persistedType);
481 617 }
482 618  
483 619 /** 录入表表头:优先 elementName,其次 inputKey,再推导 */
484 620 export function dataEntryColumnLabel(el: LabelElement): string {
  621 + const type = canonicalElementType(el.type);
485 622 const name = (el.elementName ?? "").trim();
486 623 if (name) return name;
487 624 const ik = elementInputKey(el);
488 625 if (ik) return ik;
489 626 const cfg = (el.config ?? {}) as Record<string, unknown>;
490   - if (el.type === "TEXT_PRODUCT") {
  627 + if (type === "TEXT_PRODUCT") {
491 628 const t = typeof cfg.text === "string" ? cfg.text.trim() : "";
492 629 return t || "Product name";
493 630 }
494   - if (el.type === "TEXT_PRICE") {
  631 + if (type === "TEXT_PRICE") {
495 632 const t = typeof cfg.text === "string" ? cfg.text.trim() : "";
496 633 return t || "Price";
497 634 }
498   - if (el.type === "IMAGE") return "Image";
  635 + if (type === "IMAGE") return "Image";
499 636 return printInputFieldLabel(el);
500 637 }
501 638  
... ... @@ -525,7 +662,7 @@ export function normalizeValueSourceTypeForElement(el: LabelElement): string {
525 662 if (lc.startsWith("template_") || lc.startsWith("label_")) return "FIXED";
526 663 const rawGroup = el.libraryCategory ?? "";
527 664 if (isElementLibraryCategory(rawGroup)) {
528   - return valueSourceTypeForLibraryCategory(rawGroup);
  665 + return valueSourceTypeForLibraryCategory(toCanonicalElementLibraryCategory(rawGroup));
529 666 }
530 667 if (isPrintInputElement({ ...el, valueSourceType: "" })) return "PRINT_INPUT";
531 668 return "FIXED";
... ... @@ -540,25 +677,109 @@ export function resolvedValueSourceTypeForSave(el: LabelElement): string {
540 677 * 无 libraryCategory 时的兜底(无法从 type 区分「保质期日期」与「当前日期」等同型元素,新元素应以面板点击为准)。
541 678 */
542 679 export function inferElementLibraryCategory(el: LabelElement): ElementLibraryCategory {
543   - if (isPrintInputElement(el)) return "打印时输入";
544   - switch (el.type) {
  680 + const type = canonicalElementType(el.type);
  681 + if (isPrintInputElement(el)) return "Print input";
  682 + switch (type) {
545 683 case "TEXT_PRODUCT":
546 684 case "NUTRITION":
547 685 case "DURATION":
548 686 case "TEXT_PRICE":
549 687 case "WEIGHT_PRICE":
550   - return "标签信息";
  688 + return "Label";
551 689 case "DATE":
552 690 case "TIME":
553   - return "自动生成";
  691 + return "Auto-generated";
554 692 case "TEXT_STATIC":
555 693 case "BARCODE":
556 694 case "QRCODE":
557 695 case "IMAGE":
558 696 case "BLANK":
559 697 default:
560   - return "模版信息";
  698 + return "Template";
  699 + }
  700 +}
  701 +
  702 +const ELEMENT_TYPE_SET = new Set<ElementType>([
  703 + "TEXT_STATIC",
  704 + "TEXT_PRODUCT",
  705 + "TEXT_PRICE",
  706 + "BARCODE",
  707 + "QRCODE",
  708 + "IMAGE",
  709 + "DATE",
  710 + "TIME",
  711 + "DURATION",
  712 + "WEIGHT",
  713 + "WEIGHT_PRICE",
  714 + "BLANK",
  715 + "NUTRITION",
  716 +]);
  717 +
  718 +const COMPOSED_ELEMENT_TYPE_RE = /^(template|label|auto|print)_(.+)$/i;
  719 +
  720 +const PERSISTED_TYPE_BY_GROUP_AND_LABEL: Record<string, ElementType> = {
  721 + "template|text": "TEXT_STATIC",
  722 + "template|qr code": "QRCODE",
  723 + "template|barcode": "BARCODE",
  724 + "template|blank space": "BLANK",
  725 + "template|price": "TEXT_PRICE",
  726 + "template|image": "IMAGE",
  727 + "template|logo": "IMAGE",
  728 + "label|label name": "TEXT_PRODUCT",
  729 + "label|text": "TEXT_STATIC",
  730 + "label|qr code": "QRCODE",
  731 + "label|barcode": "BARCODE",
  732 + "label|nutrition facts": "NUTRITION",
  733 + "label|price": "TEXT_PRICE",
  734 + "label|duration date": "DATE",
  735 + "label|duration time": "TIME",
  736 + "label|duration": "DURATION",
  737 + "label|image": "IMAGE",
  738 + "label|label type": "TEXT_STATIC",
  739 + "label|how-to": "TEXT_STATIC",
  740 + "label|expiration alert": "TEXT_STATIC",
  741 + "auto|company": "TEXT_STATIC",
  742 + "auto|employee": "TEXT_STATIC",
  743 + "auto|current date": "DATE",
  744 + "auto|current time": "TIME",
  745 + "auto|label id": "TEXT_STATIC",
  746 + "print|text": "TEXT_STATIC",
  747 + "print|weight": "WEIGHT",
  748 + "print|number": "TEXT_STATIC",
  749 + "print|date & time": "DATE",
  750 + "print|multiple options": "TEXT_STATIC",
  751 +};
  752 +
  753 +function normalizePaletteLabelForKey(raw: string): string {
  754 + return String(raw ?? "").trim().toLowerCase().replace(/\s+/g, " ");
  755 +}
  756 +
  757 +export function canonicalElementType(raw: ElementTypeValue): ElementType {
  758 + const type = String(raw ?? "").trim();
  759 + if (ELEMENT_TYPE_SET.has(type as ElementType)) return type as ElementType;
  760 + const m = type.match(COMPOSED_ELEMENT_TYPE_RE);
  761 + if (m) {
  762 + const group = m[1].toLowerCase();
  763 + const label = normalizePaletteLabelForKey(m[2]);
  764 + const mapped = PERSISTED_TYPE_BY_GROUP_AND_LABEL[`${group}|${label}`];
  765 + if (mapped) return mapped;
561 766 }
  767 + return "TEXT_STATIC";
  768 +}
  769 +
  770 +export function composeElementTypeForPersist(
  771 + group: ElementLibraryCategory,
  772 + paletteFieldName: string,
  773 +): string {
  774 + return composeLibraryCategoryForPersist(group, paletteFieldName);
  775 +}
  776 +
  777 +export function resolvedElementTypeForPersist(el: LabelElement): string {
  778 + const raw = String(el.type ?? "").trim();
  779 + if (COMPOSED_ELEMENT_TYPE_RE.test(raw)) return raw;
  780 + const group = inferElementLibraryCategory(el);
  781 + const fieldName = inferPaletteEnglishLabel(el);
  782 + return composeElementTypeForPersist(group, fieldName);
562 783 }
563 784  
564 785 /** 导出 / 提交后端时写入的 libraryCategory 字符串 */
... ... @@ -566,7 +787,9 @@ export function resolvedLibraryCategoryForPersist(el: LabelElement): string {
566 787 const c = el.libraryCategory?.trim();
567 788 if (c && isComposedLibraryCategoryValue(c)) return c;
568 789 const group =
569   - c && isElementLibraryCategory(c) ? c : inferElementLibraryCategory(el);
  790 + c && isElementLibraryCategory(c)
  791 + ? toCanonicalElementLibraryCategory(c)
  792 + : inferElementLibraryCategory(el);
570 793 const fieldName = inferPaletteEnglishLabel(el);
571 794 return composeLibraryCategoryForPersist(group, fieldName);
572 795 }
... ...