Commit 58d2e61cad2119c92ef9b920b0bacd13bb4dded5
1 parent
876db888
最新代码
Showing
43 changed files
with
4184 additions
and
1102 deletions
打印机安卓基座/README.md
打印机安卓基座/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
美国版/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 '../../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 = () => { |
| 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 () => { |
| 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<Record<string, string[]>>({}) |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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<'card' | 'list'>('card') |
| 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<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) => { |
| 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 = () => { |
| 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 = () => { |
| 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 () => { |
| 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<string | null> { |
| 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<string | null> { |
| 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: '' | 'bluetooth' | 'builtin'): 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) => |
| 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 '../../lib/paginationQuery'; |
| 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<string, unknown>, 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 '../../ |
| 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 "../../types/labelCategory"; |
| 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 | } | ... | ... |