Commit a001da6da1d4d31cba417d00a8e1fcb083435ab0
1 parent
ca4ab0f7
APP 预览打印
Showing
22 changed files
with
1131 additions
and
182 deletions
美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
| ... | ... | @@ -156,11 +156,14 @@ |
| 156 | 156 | </view> |
| 157 | 157 | </view> |
| 158 | 158 | |
| 159 | + <!-- 与 printers 页 Test Print 一致:必须绑定 :width/:height 作为绘图缓冲区像素,否则 canvasGetImageData 多为空白/默认尺寸,打印不出纸 --> | |
| 159 | 160 | <canvas |
| 160 | 161 | canvas-id="labelPreviewCanvas" |
| 161 | 162 | id="labelPreviewCanvas" |
| 162 | 163 | class="hidden-canvas" |
| 163 | 164 | :style="{ width: canvasCssW + 'px', height: canvasCssH + 'px' }" |
| 165 | + :width="canvasCssW" | |
| 166 | + :height="canvasCssH" | |
| 164 | 167 | /> |
| 165 | 168 | |
| 166 | 169 | <NoPrinterModal v-model="showNoPrinterModal" @connect="goBluetoothPage" /> |
| ... | ... | @@ -178,14 +181,16 @@ import LocationPicker from '../../components/LocationPicker.vue' |
| 178 | 181 | import NoPrinterModal from '../../components/NoPrinterModal.vue' |
| 179 | 182 | import { getStatusBarHeight } from '../../utils/statusBar' |
| 180 | 183 | import { |
| 184 | + canPrintCurrentLabelViaNativeFastJob, | |
| 181 | 185 | getCurrentPrinterSummary, |
| 182 | - printSystemTemplateForCurrentPrinter, | |
| 186 | + getCurrentPrinterDriver, | |
| 187 | + printImageForCurrentPrinter, | |
| 188 | + printLabelPrintJobPayloadForCurrentPrinter, | |
| 183 | 189 | } from '../../utils/print/manager/printerManager' |
| 184 | 190 | import type { SystemLabelTemplate, SystemTemplateElementBase } from '../../utils/print/types/printer' |
| 185 | 191 | import { fetchLabelMultipleOptionById } from '../../services/labelMultipleOption' |
| 186 | 192 | import { |
| 187 | 193 | buildPrintInputJson, |
| 188 | - printInputJsonToLabelTemplateData, | |
| 189 | 194 | ensureFreeFieldKeys, |
| 190 | 195 | isPrintInputFreeFieldElement, |
| 191 | 196 | isPrintInputOptionsElement, |
| ... | ... | @@ -196,8 +201,20 @@ import { |
| 196 | 201 | validatePrintInputFreeFieldsBeforePrint, |
| 197 | 202 | validatePrintInputOptionsBeforePrint, |
| 198 | 203 | } from '../../utils/labelPreview/printInputOptions' |
| 204 | +import { | |
| 205 | + buildLabelPrintJobPayload, | |
| 206 | + setLastLabelPrintJobPayload, | |
| 207 | +} from '../../utils/labelPreview/buildLabelPrintPayload' | |
| 199 | 208 | import { getCurrentStoreId } from '../../utils/stores' |
| 200 | -import { postUsAppLabelPreview, postUsAppLabelPrint } from '../../services/usAppLabeling' | |
| 209 | +import { | |
| 210 | + buildApiUrl, | |
| 211 | +} from '../../utils/apiBase' | |
| 212 | +import { | |
| 213 | + buildUsAppLabelPrintRequestBody, | |
| 214 | + postUsAppLabelPreview, | |
| 215 | + postUsAppLabelPrint, | |
| 216 | + US_APP_LABEL_PRINT_PATH, | |
| 217 | +} from '../../services/usAppLabeling' | |
| 201 | 218 | import { |
| 202 | 219 | applyTemplateProductDefaultValuesToTemplate, |
| 203 | 220 | extractTemplateProductDefaultValuesFromPreviewPayload, |
| ... | ... | @@ -205,11 +222,15 @@ import { |
| 205 | 222 | overlayProductNameOnPreviewTemplate, |
| 206 | 223 | } from '../../utils/labelPreview/normalizePreviewTemplate' |
| 207 | 224 | import { |
| 225 | + getLabelPrintRasterLayout, | |
| 208 | 226 | getPreviewCanvasCssSize, |
| 227 | + renderLabelPreviewCanvasToTempPathForPrint, | |
| 209 | 228 | renderLabelPreviewToTempPath, |
| 210 | 229 | } from '../../utils/labelPreview/renderLabelPreviewCanvas' |
| 211 | -import { isPrinterReadySync, checkBluetoothAdapterAvailable } from '../../utils/print/printerReadiness' | |
| 212 | -import { getBluetoothConnection, getPrinterType } from '../../utils/print/printerConnection' | |
| 230 | +import { templateHasUnsupportedNativeFastElements } from '../../utils/print/nativeTemplateElementSupport' | |
| 231 | +import { isTemplateWithinNativeFastPrintBounds } from '../../utils/print/templatePhysicalMm' | |
| 232 | +import { isPrinterReadySync } from '../../utils/print/printerReadiness' | |
| 233 | +import { getBluetoothConnection } from '../../utils/print/printerConnection' | |
| 213 | 234 | import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' |
| 214 | 235 | |
| 215 | 236 | const statusBarHeight = getStatusBarHeight() |
| ... | ... | @@ -244,7 +265,6 @@ const basePreviewTemplate = ref<SystemLabelTemplate | null>(null) |
| 244 | 265 | const printOptionSelections = ref<Record<string, string[]>>({}) |
| 245 | 266 | const dictLabelsByElementId = ref<Record<string, string>>({}) |
| 246 | 267 | const dictValuesByElementId = ref<Record<string, string[]>>({}) |
| 247 | -const printTaskId = ref('') | |
| 248 | 268 | const printFreeFieldValues = ref<Record<string, string>>({}) |
| 249 | 269 | |
| 250 | 270 | let freeFieldPreviewTimer: ReturnType<typeof setTimeout> | null = null |
| ... | ... | @@ -364,9 +384,16 @@ const canvasCssW = ref(300) |
| 364 | 384 | const canvasCssH = ref(200) |
| 365 | 385 | |
| 366 | 386 | onShow(() => { |
| 387 | + btConnected.value = isPrinterReadySync() | |
| 367 | 388 | const summary = getCurrentPrinterSummary() |
| 368 | - btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin' | |
| 369 | - btDeviceName.value = summary.displayName || '' | |
| 389 | + const conn = getBluetoothConnection() | |
| 390 | + if (summary.type === 'bluetooth' && conn?.deviceName) { | |
| 391 | + btDeviceName.value = conn.deviceName | |
| 392 | + } else if (summary.type === 'builtin') { | |
| 393 | + btDeviceName.value = summary.driverName || 'Built-in' | |
| 394 | + } else { | |
| 395 | + btDeviceName.value = summary.displayName || '' | |
| 396 | + } | |
| 370 | 397 | const name = uni.getStorageSync('storeName') |
| 371 | 398 | if (typeof name === 'string' && name.trim()) locationName.value = name.trim() |
| 372 | 399 | }) |
| ... | ... | @@ -528,20 +555,6 @@ const handlePrint = async () => { |
| 528 | 555 | return |
| 529 | 556 | } |
| 530 | 557 | |
| 531 | - if (getPrinterType() === 'bluetooth') { | |
| 532 | - const adapterOk = await checkBluetoothAdapterAvailable() | |
| 533 | - if (!adapterOk) { | |
| 534 | - showNoPrinterModal.value = true | |
| 535 | - return | |
| 536 | - } | |
| 537 | - } | |
| 538 | - | |
| 539 | - const loc = getCurrentStoreId() | |
| 540 | - if (!loc) { | |
| 541 | - uni.showToast({ title: 'No store selected.', icon: 'none' }) | |
| 542 | - return | |
| 543 | - } | |
| 544 | - | |
| 545 | 558 | const tmplForValidate = basePreviewTemplate.value ?? systemTemplate.value |
| 546 | 559 | if (tmplForValidate) { |
| 547 | 560 | const optErr = validatePrintInputOptionsBeforePrint( |
| ... | ... | @@ -565,42 +578,135 @@ const handlePrint = async () => { |
| 565 | 578 | |
| 566 | 579 | isPrinting.value = true |
| 567 | 580 | try { |
| 568 | - uni.showLoading({ title: 'Submitting print…', mask: true }) | |
| 569 | - const bt = getBluetoothConnection() | |
| 570 | - /** 与当前画布一致:用合并后的模板(多选项 + 自由输入 + 平台默认值)组装 printInputJson,key 与控件 inputKey/elementName 对齐 */ | |
| 581 | + uni.showLoading({ title: 'Rendering…', mask: true }) | |
| 582 | + /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */ | |
| 571 | 583 | const mergedForPrint = computeMergedPreviewTemplate() |
| 572 | - const tmplForJson = | |
| 573 | - mergedForPrint ?? basePreviewTemplate.value ?? systemTemplate.value ?? null | |
| 574 | - const pj = | |
| 575 | - tmplForJson != null | |
| 576 | - ? buildPrintInputJson( | |
| 577 | - tmplForJson, | |
| 578 | - printOptionSelections.value, | |
| 579 | - printFreeFieldValues.value | |
| 580 | - ) | |
| 581 | - : {} | |
| 582 | - const templateDataForPrinter = printInputJsonToLabelTemplateData(pj) | |
| 583 | - const out = await postUsAppLabelPrint({ | |
| 584 | - locationId: loc, | |
| 584 | + const tmpl = mergedForPrint ?? systemTemplate.value | |
| 585 | + if (!tmpl || !instance) { | |
| 586 | + throw new Error('No label to print.') | |
| 587 | + } | |
| 588 | + | |
| 589 | + const printInputJson = buildPrintInputJson( | |
| 590 | + tmpl, | |
| 591 | + printOptionSelections.value, | |
| 592 | + printFreeFieldValues.value | |
| 593 | + ) | |
| 594 | + const labelPrintJobPayload = buildLabelPrintJobPayload(tmpl, printInputJson, { | |
| 585 | 595 | labelCode: labelCode.value, |
| 586 | 596 | productId: productId.value || undefined, |
| 587 | 597 | printQuantity: printQty.value, |
| 588 | - printerMac: bt?.deviceId || undefined, | |
| 589 | - printerAddress: bt?.deviceId || undefined, | |
| 590 | - printInputJson: Object.keys(pj).length > 0 ? pj : undefined, | |
| 598 | + locationId: getCurrentStoreId() || undefined, | |
| 591 | 599 | }) |
| 592 | - printTaskId.value = out.taskId || '' | |
| 593 | - if (printTaskId.value) labelIdDisplay.value = printTaskId.value | |
| 600 | + setLastLabelPrintJobPayload(labelPrintJobPayload) | |
| 601 | + | |
| 602 | + /** | |
| 603 | + * 原生 printTemplate:① 物理尺寸超常见标签幅宽则回退光栅;② 含 WEIGHT/DATE/LOGO 等原生未实现类型时回退光栅(与画布一致,避免假成功)。 | |
| 604 | + */ | |
| 605 | + const useNativeTemplatePrint = | |
| 606 | + canPrintCurrentLabelViaNativeFastJob() | |
| 607 | + && isTemplateWithinNativeFastPrintBounds(tmpl) | |
| 608 | + && !templateHasUnsupportedNativeFastElements(tmpl) | |
| 609 | + | |
| 610 | + if (useNativeTemplatePrint) { | |
| 611 | + /** 经典蓝牙 + native-fast-printer:template + printInputJson → 原生 printTemplate(仅物理尺寸在热敏标签可打范围内) */ | |
| 612 | + uni.showLoading({ title: 'Printing…', mask: true }) | |
| 613 | + await printLabelPrintJobPayloadForCurrentPrinter( | |
| 614 | + labelPrintJobPayload, | |
| 615 | + { printQty: printQty.value }, | |
| 616 | + (percent) => { | |
| 617 | + if (percent > 5 && percent < 100) { | |
| 618 | + uni.showLoading({ title: `Printing ${percent}%`, mask: true }) | |
| 619 | + } | |
| 620 | + } | |
| 621 | + ) | |
| 622 | + } else { | |
| 623 | + const driver = getCurrentPrinterDriver() | |
| 624 | + const maxDots = | |
| 625 | + driver.imageMaxWidthDots || (driver.protocol === 'esc' ? 384 : 576) | |
| 626 | + const layout = getLabelPrintRasterLayout(tmpl, maxDots, driver.imageDpi || 203) | |
| 627 | + | |
| 628 | + canvasCssW.value = layout.outW | |
| 629 | + canvasCssH.value = layout.outH | |
| 630 | + await nextTick() | |
| 631 | + await new Promise<void>((r) => setTimeout(r, 50)) | |
| 632 | + | |
| 633 | + const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint( | |
| 634 | + 'labelPreviewCanvas', | |
| 635 | + instance, | |
| 636 | + tmpl, | |
| 637 | + layout | |
| 638 | + ) | |
| 639 | + | |
| 640 | + uni.showLoading({ title: 'Printing…', mask: true }) | |
| 641 | + /** 与历史验证路径一致:临时 PNG → 解码光栅 → TSC(避免预览页 canvasGetImageData 与打印机页行为不一致) */ | |
| 642 | + await printImageForCurrentPrinter( | |
| 643 | + tmpPath, | |
| 644 | + { | |
| 645 | + printQty: printQty.value, | |
| 646 | + clearTopRasterRows: 1, | |
| 647 | + targetWidthDots: layout.outW, | |
| 648 | + targetHeightDots: layout.outH, | |
| 649 | + }, | |
| 650 | + (percent) => { | |
| 651 | + if (percent > 5 && percent < 100) { | |
| 652 | + uni.showLoading({ title: `Printing ${percent}%`, mask: true }) | |
| 653 | + } | |
| 654 | + } | |
| 655 | + ) | |
| 656 | + } | |
| 657 | + | |
| 658 | + /** 接口 9:仅本页业务标签出纸后落库(打印机设置/蓝牙 Test Print 不会执行此段) */ | |
| 659 | + let printLogSyncFailed = false | |
| 660 | + let printLogRequestBody: ReturnType<typeof buildUsAppLabelPrintRequestBody> = null | |
| 661 | + try { | |
| 662 | + const bt = getBluetoothConnection() | |
| 663 | + printLogRequestBody = buildUsAppLabelPrintRequestBody({ | |
| 664 | + locationId: getCurrentStoreId(), | |
| 665 | + labelCode: labelCode.value, | |
| 666 | + productId: productId.value || undefined, | |
| 667 | + printQuantity: printQty.value, | |
| 668 | + printInputJson, | |
| 669 | + templateSnapshot: labelPrintJobPayload.template, | |
| 670 | + printerMac: bt?.deviceId || undefined, | |
| 671 | + }) | |
| 672 | + if (printLogRequestBody) { | |
| 673 | + await postUsAppLabelPrint(printLogRequestBody) | |
| 674 | + } | |
| 675 | + } catch (syncErr: unknown) { | |
| 676 | + if (!isUsAppSessionExpiredError(syncErr)) { | |
| 677 | + printLogSyncFailed = true | |
| 678 | + const msg = syncErr instanceof Error ? syncErr.message : String(syncErr) | |
| 679 | + console.error('[preview] 打印落库接口失败', { | |
| 680 | + 接口说明: 'App 打印(落库打印任务与明细)', | |
| 681 | + 接口路径: US_APP_LABEL_PRINT_PATH, | |
| 682 | + 完整URL: buildApiUrl(US_APP_LABEL_PRINT_PATH), | |
| 683 | + 方法: 'POST', | |
| 684 | + 请求体: printLogRequestBody, | |
| 685 | + 错误信息: msg, | |
| 686 | + 原始错误: syncErr, | |
| 687 | + }) | |
| 688 | + } | |
| 689 | + /** 401 时 usAppApiRequest 已 Toast + 跳转,此处不再处理 */ | |
| 690 | + } | |
| 691 | + | |
| 692 | + const sz = getPreviewCanvasCssSize(tmpl, 720) | |
| 693 | + canvasCssW.value = sz.width | |
| 694 | + canvasCssH.value = sz.height | |
| 695 | + await nextTick() | |
| 696 | + try { | |
| 697 | + const path = await renderLabelPreviewToTempPath('labelPreviewCanvas', instance, tmpl, 720) | |
| 698 | + previewImageSrc.value = path | |
| 699 | + } catch { | |
| 700 | + /* 保持上一张预览图 */ | |
| 701 | + } | |
| 594 | 702 | |
| 595 | - await printSystemTemplateForCurrentPrinter( | |
| 596 | - mergedForPrint ?? systemTemplate.value, | |
| 597 | - templateDataForPrinter, | |
| 598 | - { printQty: printQty.value } | |
| 599 | - ) | |
| 600 | 703 | uni.hideLoading() |
| 704 | + const qty = printQty.value | |
| 705 | + const printedTitle = `${qty} label${qty > 1 ? 's' : ''} printed!` | |
| 601 | 706 | uni.showToast({ |
| 602 | - title: `${printQty.value} label${printQty.value > 1 ? 's' : ''} printed!`, | |
| 603 | - icon: 'success', | |
| 707 | + title: printLogSyncFailed ? `${printedTitle} (log not saved)` : printedTitle, | |
| 708 | + icon: printLogSyncFailed ? 'none' : 'success', | |
| 709 | + duration: printLogSyncFailed ? 2800 : 2000, | |
| 604 | 710 | }) |
| 605 | 711 | } catch (e: any) { |
| 606 | 712 | uni.hideLoading() |
| ... | ... | @@ -620,6 +726,12 @@ const handlePrint = async () => { |
| 620 | 726 | uni.showToast({ title: msg, icon: 'none', duration: 3000 }) |
| 621 | 727 | } |
| 622 | 728 | } finally { |
| 729 | + const t = systemTemplate.value | |
| 730 | + if (t && instance) { | |
| 731 | + const sz = getPreviewCanvasCssSize(t, 720) | |
| 732 | + canvasCssW.value = sz.width | |
| 733 | + canvasCssH.value = sz.height | |
| 734 | + } | |
| 623 | 735 | isPrinting.value = false |
| 624 | 736 | } |
| 625 | 737 | } |
| ... | ... | @@ -1090,10 +1202,10 @@ const handlePrint = async () => { |
| 1090 | 1202 | |
| 1091 | 1203 | .hidden-canvas { |
| 1092 | 1204 | position: fixed; |
| 1093 | - left: -2000px; | |
| 1205 | + left: -9999px; | |
| 1094 | 1206 | top: 0; |
| 1095 | - width: 300px; | |
| 1096 | - height: 200px; | |
| 1207 | + opacity: 0; | |
| 1208 | + pointer-events: none; | |
| 1097 | 1209 | } |
| 1098 | 1210 | |
| 1099 | 1211 | .modal-mask { | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
| ... | ... | @@ -412,6 +412,7 @@ const disconnect = async () => { |
| 412 | 412 | uni.showToast({ title: t('printers.disconnected') }) |
| 413 | 413 | } |
| 414 | 414 | |
| 415 | +/** 测试打印:仅下发位图,不调用 `/api/app/us-app-labeling/print`(接口 9 仅预览页出纸后落库) */ | |
| 415 | 416 | const doTestPrint = async () => { |
| 416 | 417 | try { |
| 417 | 418 | uni.showLoading({ title: 'Rendering canvas...', mask: true }) |
| ... | ... | @@ -471,7 +472,8 @@ onUnmounted(() => { |
| 471 | 472 | } |
| 472 | 473 | } catch (_) {} |
| 473 | 474 | } |
| 474 | - uni.closeBluetoothAdapter({ complete: () => {} }) | |
| 475 | + // 不在离开设置页时 closeBluetoothAdapter:否则标签预览等页面打印会 writeBLECharacteristicValue:fail not init(与 Test Print 同页才正常)。 | |
| 476 | + // 需要释放蓝牙时请用户点「断开」或系统层关闭蓝牙。 | |
| 475 | 477 | }) |
| 476 | 478 | </script> |
| 477 | 479 | ... | ... |
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
| ... | ... | @@ -9,6 +9,9 @@ import type { |
| 9 | 9 | } from '../types/usAppLabeling' |
| 10 | 10 | import { usAppApiRequest } from '../utils/usAppApiRequest' |
| 11 | 11 | |
| 12 | +/** 接口 9:与文档路径一致,供日志与请求共用 */ | |
| 13 | +export const US_APP_LABEL_PRINT_PATH = '/api/app/us-app-labeling/print' as const | |
| 14 | + | |
| 12 | 15 | function asArr(v: unknown): unknown[] { |
| 13 | 16 | return Array.isArray(v) ? v : [] |
| 14 | 17 | } |
| ... | ... | @@ -98,12 +101,73 @@ export async function postUsAppLabelPreview(body: UsAppLabelPreviewInputVo): Pro |
| 98 | 101 | }) |
| 99 | 102 | } |
| 100 | 103 | |
| 101 | -/** 接口 9.1 */ | |
| 104 | +/** | |
| 105 | + * 接口 9.1 原始请求。 | |
| 106 | + * 注意:仅供业务落库场景调用;打印机设置页「测试打印」、蓝牙页 Test Print 等 **不得** 使用(避免脏数据)。 | |
| 107 | + */ | |
| 102 | 108 | export async function postUsAppLabelPrint(body: UsAppLabelPrintInputVo): Promise<UsAppLabelPrintOutputDto> { |
| 103 | 109 | return usAppApiRequest<UsAppLabelPrintOutputDto>({ |
| 104 | - path: '/api/app/us-app-labeling/print', | |
| 110 | + path: US_APP_LABEL_PRINT_PATH, | |
| 105 | 111 | method: 'POST', |
| 106 | 112 | auth: true, |
| 107 | 113 | data: body, |
| 108 | 114 | }) |
| 109 | 115 | } |
| 116 | + | |
| 117 | +export function buildUsAppLabelPrintRequestBody(input: { | |
| 118 | + locationId?: string | null | |
| 119 | + labelCode?: string | null | |
| 120 | + productId?: string | null | |
| 121 | + printQuantity: number | |
| 122 | + printInputJson: Record<string, unknown> | |
| 123 | + /** 与 buildLabelPrintJobPayload().template 同构,落库 RenderDataJson */ | |
| 124 | + templateSnapshot?: Record<string, unknown> | null | |
| 125 | + printerMac?: string | null | |
| 126 | + printerAddress?: string | null | |
| 127 | +}): UsAppLabelPrintInputVo | null { | |
| 128 | + const locationId = String(input.locationId || '').trim() | |
| 129 | + const labelCode = String(input.labelCode || '').trim() | |
| 130 | + if (!locationId || !labelCode) return null | |
| 131 | + | |
| 132 | + const body: UsAppLabelPrintInputVo = { | |
| 133 | + locationId, | |
| 134 | + labelCode, | |
| 135 | + printQuantity: Math.max(1, Math.round(Number(input.printQuantity) || 1)), | |
| 136 | + printInputJson: { ...input.printInputJson }, | |
| 137 | + baseTime: new Date().toISOString(), | |
| 138 | + } | |
| 139 | + if (input.templateSnapshot && typeof input.templateSnapshot === 'object') { | |
| 140 | + try { | |
| 141 | + body.templateSnapshot = JSON.parse(JSON.stringify(input.templateSnapshot)) as Record<string, unknown> | |
| 142 | + } catch { | |
| 143 | + /* 忽略快照克隆失败 */ | |
| 144 | + } | |
| 145 | + } | |
| 146 | + const pid = String(input.productId || '').trim() | |
| 147 | + if (pid) body.productId = pid | |
| 148 | + const mac = String(input.printerMac || '').trim() | |
| 149 | + if (mac) body.printerMac = mac | |
| 150 | + const addr = String(input.printerAddress || '').trim() | |
| 151 | + if (addr) body.printerAddress = addr | |
| 152 | + return body | |
| 153 | +} | |
| 154 | + | |
| 155 | +/** | |
| 156 | + * 接口 9:仅在 **标签预览页**(`pages/labels/preview`)用户打印真实标签、出纸成功后落库。 | |
| 157 | + * `printInputJson` 与预览/原生 dataJson 同源;缺少 `locationId` 或 `labelCode` 时不发请求。 | |
| 158 | + * 测试打印模板(printers / bluetooth Test Print)不走此函数。 | |
| 159 | + */ | |
| 160 | +export async function reportUsAppLabelPrintIfReady(input: { | |
| 161 | + locationId?: string | null | |
| 162 | + labelCode?: string | null | |
| 163 | + productId?: string | null | |
| 164 | + printQuantity: number | |
| 165 | + printInputJson: Record<string, unknown> | |
| 166 | + templateSnapshot?: Record<string, unknown> | null | |
| 167 | + printerMac?: string | null | |
| 168 | + printerAddress?: string | null | |
| 169 | +}): Promise<UsAppLabelPrintOutputDto | null> { | |
| 170 | + const body = buildUsAppLabelPrintRequestBody(input) | |
| 171 | + if (!body) return null | |
| 172 | + return postUsAppLabelPrint(body) | |
| 173 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
| ... | ... | @@ -62,7 +62,13 @@ export interface UsAppLabelPrintInputVo { |
| 62 | 62 | productId?: string |
| 63 | 63 | printQuantity?: number |
| 64 | 64 | baseTime?: string |
| 65 | + /** 扁平 PRINT_INPUT 组装(审计/再合并用);与 App 内 buildPrintInputJson 一致 */ | |
| 65 | 66 | printInputJson?: Record<string, unknown> |
| 67 | + /** | |
| 68 | + * 与平台导出 label-template-*.json 同构的合并后模板(含 elements[].config); | |
| 69 | + * 服务端优先写入明细 RenderDataJson,便于打印历史按「整模板」重打,与出纸一致。 | |
| 70 | + */ | |
| 71 | + templateSnapshot?: Record<string, unknown> | |
| 66 | 72 | printerId?: string |
| 67 | 73 | printerMac?: string |
| 68 | 74 | printerAddress?: string | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/buildLabelPrintPayload.ts
0 → 100644
| 1 | +/** | |
| 2 | + * 按平台 label-template-*.json 结构组装「打印任务」数据(合并后的模板快照 + printInputJson)。 | |
| 3 | + * 物理 BLE 打印仍走位图 TSC(与 Test Print 相同);本对象为业务/插件侧与 JSON 模板对齐的权威快照。 | |
| 4 | + */ | |
| 5 | +import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' | |
| 6 | + | |
| 7 | +/** 与仓库内 label-template-template-*.json 根结构一致 */ | |
| 8 | +export interface LabelTemplateDocumentJson { | |
| 9 | + id: string | |
| 10 | + name: string | |
| 11 | + labelType?: string | |
| 12 | + unit: string | |
| 13 | + width: number | |
| 14 | + height: number | |
| 15 | + appliedLocation?: string | |
| 16 | + showRuler?: boolean | |
| 17 | + showGrid?: boolean | |
| 18 | + elements: Record<string, unknown>[] | |
| 19 | +} | |
| 20 | + | |
| 21 | +export interface LabelPrintJobMeta { | |
| 22 | + labelCode?: string | |
| 23 | + productId?: string | |
| 24 | + printQuantity?: number | |
| 25 | + locationId?: string | |
| 26 | +} | |
| 27 | + | |
| 28 | +export interface LabelPrintJobPayload { | |
| 29 | + template: LabelTemplateDocumentJson | |
| 30 | + /** 与接口 printInputJson 一致(inputKey / elementName 等键) */ | |
| 31 | + printInputJson: Record<string, unknown> | |
| 32 | + meta?: LabelPrintJobMeta | |
| 33 | +} | |
| 34 | + | |
| 35 | +function cloneJsonSafeConfig(cfg: Record<string, unknown>): Record<string, unknown> { | |
| 36 | + try { | |
| 37 | + return JSON.parse(JSON.stringify(cfg ?? {})) as Record<string, unknown> | |
| 38 | + } catch { | |
| 39 | + return { ...(cfg || {}) } as Record<string, unknown> | |
| 40 | + } | |
| 41 | +} | |
| 42 | + | |
| 43 | +/** 单元素序列化:与 JSON 模板 elements[] 项字段对齐,并保留 PRINT_INPUT 相关根字段 */ | |
| 44 | +export function serializeElementForLabelTemplateJson(el: SystemTemplateElementBase): Record<string, unknown> { | |
| 45 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 46 | + const o: Record<string, unknown> = { | |
| 47 | + id: el.id, | |
| 48 | + type: el.type, | |
| 49 | + x: el.x, | |
| 50 | + y: el.y, | |
| 51 | + width: el.width, | |
| 52 | + height: el.height, | |
| 53 | + rotation: el.rotation ?? 'horizontal', | |
| 54 | + border: el.border ?? 'none', | |
| 55 | + config: cloneJsonSafeConfig(cfg), | |
| 56 | + } | |
| 57 | + if (el.valueSourceType) o.valueSourceType = el.valueSourceType | |
| 58 | + if (el.inputKey != null && String(el.inputKey).trim()) o.inputKey = el.inputKey | |
| 59 | + if (el.elementName != null && String(el.elementName).trim()) o.elementName = el.elementName | |
| 60 | + return o | |
| 61 | +} | |
| 62 | + | |
| 63 | +/** | |
| 64 | + * 从当前合并模板 + 已组好的 printInputJson 生成与 label-template JSON 同构的打印载荷。 | |
| 65 | + */ | |
| 66 | +export function buildLabelPrintJobPayload( | |
| 67 | + merged: SystemLabelTemplate, | |
| 68 | + printInputJson: Record<string, unknown>, | |
| 69 | + meta?: LabelPrintJobMeta | |
| 70 | +): LabelPrintJobPayload { | |
| 71 | + const template: LabelTemplateDocumentJson = { | |
| 72 | + id: String(merged.id || ''), | |
| 73 | + name: String(merged.name || '未命名模板'), | |
| 74 | + labelType: merged.labelType, | |
| 75 | + unit: String(merged.unit || 'inch'), | |
| 76 | + width: Number(merged.width) || 0, | |
| 77 | + height: Number(merged.height) || 0, | |
| 78 | + appliedLocation: merged.appliedLocation || 'ALL', | |
| 79 | + showRuler: merged.showRuler !== false, | |
| 80 | + showGrid: merged.showGrid !== false, | |
| 81 | + elements: (merged.elements || []).map(serializeElementForLabelTemplateJson), | |
| 82 | + } | |
| 83 | + return { | |
| 84 | + template, | |
| 85 | + printInputJson: { ...printInputJson }, | |
| 86 | + meta: meta ? { ...meta } : undefined, | |
| 87 | + } | |
| 88 | +} | |
| 89 | + | |
| 90 | +let lastLabelPrintJobPayload: LabelPrintJobPayload | null = null | |
| 91 | + | |
| 92 | +/** 供调试或后续原生插件读取最近一次组装的任务(template + printInputJson) */ | |
| 93 | +export function setLastLabelPrintJobPayload(p: LabelPrintJobPayload): void { | |
| 94 | + lastLabelPrintJobPayload = p | |
| 95 | +} | |
| 96 | + | |
| 97 | +export function getLastLabelPrintJobPayload(): LabelPrintJobPayload | null { | |
| 98 | + return lastLabelPrintJobPayload | |
| 99 | +} | |
| 100 | + | |
| 101 | +/** 与原生 printTemplate 入参一致:插件侧可对齐平台导出 JSON */ | |
| 102 | +export function stringifyLabelPrintJobForNative(p: LabelPrintJobPayload): { | |
| 103 | + templateJson: string | |
| 104 | + dataJson: string | |
| 105 | +} { | |
| 106 | + return { | |
| 107 | + templateJson: JSON.stringify(p.template), | |
| 108 | + dataJson: JSON.stringify(p.printInputJson ?? {}), | |
| 109 | + } | |
| 110 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
| ... | ... | @@ -48,21 +48,60 @@ function readFillColor(config: Record<string, any>): string { |
| 48 | 48 | function maxCharsPerLine(innerWidthPx: number, fontSize: number): number { |
| 49 | 49 | if (innerWidthPx <= 4) return 8 |
| 50 | 50 | const approx = Math.max(0.45, Math.min(0.75, 0.55)) |
| 51 | - return Math.max(4, Math.floor(innerWidthPx / (fontSize * approx))) | |
| 51 | + /** 下限 8:避免过窄估算时按 4 字硬切把英文单词拦腰截断(如 All|ergens) */ | |
| 52 | + return Math.max(8, Math.floor(innerWidthPx / (fontSize * approx))) | |
| 52 | 53 | } |
| 53 | 54 | |
| 54 | -function wrapTextToWidth(text: string, maxChars: number): string[] { | |
| 55 | - const lines = String(text).split(/\r?\n/) | |
| 55 | +/** | |
| 56 | + * 优先在空格处断行,长词再按字符切分;避免固定宽度硬切破坏英文单词与「标签: 值」可读性。 | |
| 57 | + */ | |
| 58 | +function wrapSingleLogicalLine(line: string, maxChars: number): string[] { | |
| 59 | + const limit = Math.max(8, maxChars) | |
| 60 | + const s = String(line) | |
| 61 | + if (s.length <= limit) return [s] | |
| 62 | + | |
| 63 | + const words = s.split(/(\s+)/) | |
| 56 | 64 | const out: string[] = [] |
| 57 | - for (const line of lines) { | |
| 58 | - if (line.length <= maxChars) { | |
| 59 | - out.push(line) | |
| 65 | + let cur = '' | |
| 66 | + | |
| 67 | + const pushLongToken = (token: string) => { | |
| 68 | + for (let i = 0; i < token.length; i += limit) { | |
| 69 | + out.push(token.slice(i, i + limit)) | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + for (const w of words) { | |
| 74 | + if (/^\s+$/.test(w)) { | |
| 75 | + cur += w | |
| 60 | 76 | continue |
| 61 | 77 | } |
| 62 | - for (let i = 0; i < line.length; i += maxChars) { | |
| 63 | - out.push(line.slice(i, i + maxChars)) | |
| 78 | + if (!w) continue | |
| 79 | + | |
| 80 | + const trimmedRight = cur.replace(/\s+$/, '') | |
| 81 | + const candidate = trimmedRight ? `${trimmedRight} ${w}` : w | |
| 82 | + if (candidate.length <= limit) { | |
| 83 | + cur = candidate | |
| 84 | + } else { | |
| 85 | + if (trimmedRight) out.push(trimmedRight) | |
| 86 | + cur = '' | |
| 87 | + if (w.length > limit) { | |
| 88 | + pushLongToken(w) | |
| 89 | + } else { | |
| 90 | + cur = w | |
| 91 | + } | |
| 64 | 92 | } |
| 65 | 93 | } |
| 94 | + const tail = cur.replace(/\s+$/, '') | |
| 95 | + if (tail) out.push(tail) | |
| 96 | + return out.length ? out : [''] | |
| 97 | +} | |
| 98 | + | |
| 99 | +function wrapTextToWidth(text: string, maxChars: number): string[] { | |
| 100 | + const lines = String(text).split(/\r?\n/) | |
| 101 | + const out: string[] = [] | |
| 102 | + for (const line of lines) { | |
| 103 | + out.push(...wrapSingleLogicalLine(line, maxChars)) | |
| 104 | + } | |
| 66 | 105 | return out.length ? out : [''] |
| 67 | 106 | } |
| 68 | 107 | |
| ... | ... | @@ -125,26 +164,18 @@ function previewExportPixelRatio(): number { |
| 125 | 164 | } |
| 126 | 165 | } |
| 127 | 166 | |
| 128 | -/** | |
| 129 | - * 将模板绘制到 canvas,并导出临时路径供 <image> 展示。 | |
| 130 | - */ | |
| 131 | -export function renderLabelPreviewToTempPath( | |
| 167 | +/** 与屏幕预览 / 位图打印共用绘制逻辑(坐标系:设计宽 cw × ch,ctx 已 scale) */ | |
| 168 | +function runLabelPreviewCanvasDraw( | |
| 132 | 169 | canvasId: string, |
| 133 | 170 | componentInstance: any, |
| 134 | 171 | template: SystemLabelTemplate, |
| 135 | - maxDisplayWidthPx = 720 | |
| 136 | -): Promise<string> { | |
| 137 | - const unit = template.unit || 'inch' | |
| 138 | - const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) | |
| 139 | - const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 140 | - const scale = Math.min(1, maxDisplayWidthPx / cw) | |
| 141 | - const outW = Math.max(1, Math.round(cw * scale)) | |
| 142 | - const outH = Math.max(1, Math.round(ch * scale)) | |
| 143 | - const exportPr = previewExportPixelRatio() | |
| 144 | - | |
| 172 | + cw: number, | |
| 173 | + ch: number, | |
| 174 | + scale: number | |
| 175 | +): Promise<void> { | |
| 145 | 176 | const sorted = sortElementsForPreview(template.elements || []) |
| 146 | 177 | |
| 147 | - return new Promise((resolve, reject) => { | |
| 178 | + return new Promise((resolve) => { | |
| 148 | 179 | const ctx = uni.createCanvasContext(canvasId, componentInstance) |
| 149 | 180 | ctx.setFillStyle('#ffffff') |
| 150 | 181 | ctx.scale(scale, scale) |
| ... | ... | @@ -152,22 +183,7 @@ export function renderLabelPreviewToTempPath( |
| 152 | 183 | |
| 153 | 184 | const drawRest = (index: number) => { |
| 154 | 185 | if (index >= sorted.length) { |
| 155 | - ctx.draw(false, () => { | |
| 156 | - setTimeout(() => { | |
| 157 | - uni.canvasToTempFilePath( | |
| 158 | - { | |
| 159 | - canvasId, | |
| 160 | - width: outW, | |
| 161 | - height: outH, | |
| 162 | - destWidth: Math.round(outW * exportPr), | |
| 163 | - destHeight: Math.round(outH * exportPr), | |
| 164 | - success: (res) => resolve(res.tempFilePath), | |
| 165 | - fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath failed')), | |
| 166 | - }, | |
| 167 | - componentInstance | |
| 168 | - ) | |
| 169 | - }, 120) | |
| 170 | - }) | |
| 186 | + ctx.draw(false, () => resolve()) | |
| 171 | 187 | return |
| 172 | 188 | } |
| 173 | 189 | |
| ... | ... | @@ -281,6 +297,102 @@ export function renderLabelPreviewToTempPath( |
| 281 | 297 | }) |
| 282 | 298 | } |
| 283 | 299 | |
| 300 | +/** | |
| 301 | + * 将模板绘制到 canvas,并导出临时路径供 <image> 展示。 | |
| 302 | + */ | |
| 303 | +export function renderLabelPreviewToTempPath( | |
| 304 | + canvasId: string, | |
| 305 | + componentInstance: any, | |
| 306 | + template: SystemLabelTemplate, | |
| 307 | + maxDisplayWidthPx = 720 | |
| 308 | +): Promise<string> { | |
| 309 | + const unit = template.unit || 'inch' | |
| 310 | + const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) | |
| 311 | + const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 312 | + const scale = Math.min(1, maxDisplayWidthPx / cw) | |
| 313 | + const outW = Math.max(1, Math.round(cw * scale)) | |
| 314 | + const outH = Math.max(1, Math.round(ch * scale)) | |
| 315 | + const exportPr = previewExportPixelRatio() | |
| 316 | + | |
| 317 | + return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then( | |
| 318 | + () => | |
| 319 | + new Promise<string>((resolve, reject) => { | |
| 320 | + setTimeout(() => { | |
| 321 | + uni.canvasToTempFilePath( | |
| 322 | + { | |
| 323 | + canvasId, | |
| 324 | + width: outW, | |
| 325 | + height: outH, | |
| 326 | + destWidth: Math.round(outW * exportPr), | |
| 327 | + destHeight: Math.round(outH * exportPr), | |
| 328 | + success: (res) => resolve(res.tempFilePath), | |
| 329 | + fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath failed')), | |
| 330 | + }, | |
| 331 | + componentInstance | |
| 332 | + ) | |
| 333 | + }, 120) | |
| 334 | + }) | |
| 335 | + ) | |
| 336 | +} | |
| 337 | + | |
| 338 | +/** | |
| 339 | + * 打印专用:与屏幕预览相同走 canvasToTempFilePath(已验证能出图),再由 printImageForCurrentPrinter 用原生 Bitmap 解码光栅化。 | |
| 340 | + * 避免 canvasGetImageData 在部分机型/页面上下文中返回空或错位,导致 BLE 发出“空标签”仍回调成功。 | |
| 341 | + */ | |
| 342 | +export function renderLabelPreviewCanvasToTempPathForPrint( | |
| 343 | + canvasId: string, | |
| 344 | + componentInstance: any, | |
| 345 | + template: SystemLabelTemplate, | |
| 346 | + layout: { cw: number, ch: number, outW: number, outH: number, scale: number } | |
| 347 | +): Promise<string> { | |
| 348 | + const { cw, ch, outW, outH, scale } = layout | |
| 349 | + return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then( | |
| 350 | + () => | |
| 351 | + new Promise<string>((resolve, reject) => { | |
| 352 | + setTimeout(() => { | |
| 353 | + uni.canvasToTempFilePath( | |
| 354 | + { | |
| 355 | + canvasId, | |
| 356 | + x: 0, | |
| 357 | + y: 0, | |
| 358 | + width: outW, | |
| 359 | + height: outH, | |
| 360 | + destWidth: outW, | |
| 361 | + destHeight: outH, | |
| 362 | + fileType: 'png', | |
| 363 | + quality: 1, | |
| 364 | + success: (res) => resolve(res.tempFilePath), | |
| 365 | + fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath for print failed')), | |
| 366 | + }, | |
| 367 | + componentInstance | |
| 368 | + ) | |
| 369 | + }, 150) | |
| 370 | + }) | |
| 371 | + ) | |
| 372 | +} | |
| 373 | + | |
| 374 | +/** | |
| 375 | + * 按打印机最大宽度(dots)与 DPI 计算栅格尺寸;宽为 8 的倍数,与 Test Print / rasterizeImageData 一致。 | |
| 376 | + */ | |
| 377 | +export function getLabelPrintRasterLayout( | |
| 378 | + template: SystemLabelTemplate, | |
| 379 | + maxWidthDots: number, | |
| 380 | + printDpi = 203 | |
| 381 | +): { cw: number, ch: number, outW: number, outH: number, scale: number } { | |
| 382 | + const unit = template.unit || 'inch' | |
| 383 | + const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) | |
| 384 | + const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 385 | + const designDpi = 96 | |
| 386 | + const idealW = Math.round(cw * (printDpi / designDpi)) | |
| 387 | + const cap = Math.max(8, Math.round(maxWidthDots || 576)) | |
| 388 | + let outW = Math.max(8, Math.min(cap, idealW)) | |
| 389 | + outW -= outW % 8 | |
| 390 | + if (outW < 8) outW = 8 | |
| 391 | + const scale = outW / cw | |
| 392 | + const outH = Math.max(1, Math.round(ch * scale)) | |
| 393 | + return { cw, ch, outW, outH, scale } | |
| 394 | +} | |
| 395 | + | |
| 284 | 396 | export function getPreviewCanvasCssSize(template: SystemLabelTemplate, maxDisplayWidthPx = 720): { |
| 285 | 397 | width: number |
| 286 | 398 | height: number | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts
0 → 100644
| 1 | +/** | |
| 2 | + * 部分标签机 BLE 串口:GATT 同时声明 write / writeNoResponse,但数据口实际只接受 Write Command(无响应)。 | |
| 3 | + * 用默认「带响应写」时常见首包过、第二包起 writeBLECharacteristicValue:fail property not support (10007)。 | |
| 4 | + * printerConnection 对白名单 UUID 会强制全程 writeNoResponse,且禁止在 10007 时翻成「带响应写」(否则长任务/多份打印必挂)。 | |
| 5 | + */ | |
| 6 | + | |
| 7 | +export function normalizeBleUuid (uuid: string): string { | |
| 8 | + return String(uuid || '').replace(/-/g, '').toLowerCase() | |
| 9 | +} | |
| 10 | + | |
| 11 | +/** serviceUuid + characteristicUuid(无横线小写) */ | |
| 12 | +const FORCE_WRITE_NO_RESPONSE: Array<{ service: string; characteristic: string }> = [ | |
| 13 | + /** 佳博 GP-D320FX 等常见 Nordic UART 风格串口(与你机子日志一致) */ | |
| 14 | + { | |
| 15 | + service: '49535343fe7d4ae58fa99fafd205e455', | |
| 16 | + characteristic: '49535343884143f4a8d4ecbe34729bb3', | |
| 17 | + }, | |
| 18 | +] | |
| 19 | + | |
| 20 | +export function blePairRequiresWriteNoResponse ( | |
| 21 | + serviceId: string, | |
| 22 | + characteristicId: string | |
| 23 | +): boolean { | |
| 24 | + const s = normalizeBleUuid(serviceId) | |
| 25 | + const c = normalizeBleUuid(characteristicId) | |
| 26 | + return FORCE_WRITE_NO_RESPONSE.some((p) => p.service === s && p.characteristic === c) | |
| 27 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
| ... | ... | @@ -129,14 +129,26 @@ export async function rasterizeImageForPrinter ( |
| 129 | 129 | const protocolMaxWidth = getDefaultMaxWidthDots(driver) |
| 130 | 130 | const maxWidthDots = options.maxWidthDots && options.maxWidthDots > 0 ? options.maxWidthDots : protocolMaxWidth |
| 131 | 131 | const targetWidth = ensureMultipleOf8(options.targetWidthDots || sourceWidth, maxWidthDots) |
| 132 | - const aspectRatio = sourceHeight / sourceWidth | |
| 133 | - const targetHeight = Math.max( | |
| 134 | - 1, | |
| 135 | - Math.round(options.targetHeightDots || (targetWidth * aspectRatio)) | |
| 136 | - ) | |
| 132 | + const hasFixedTargetBox = | |
| 133 | + options.targetWidthDots != null && | |
| 134 | + options.targetWidthDots > 0 && | |
| 135 | + options.targetHeightDots != null && | |
| 136 | + options.targetHeightDots > 0 | |
| 137 | + let targetHeight: number | |
| 138 | + if (hasFixedTargetBox) { | |
| 139 | + /** 与 getLabelPrintRasterLayout 的 outW×outH 一致,避免 PNG 实际像素与画布布局偏差导致 TSC 错位 */ | |
| 140 | + targetHeight = Math.max(1, Math.round(Number(options.targetHeightDots))) | |
| 141 | + } else { | |
| 142 | + const aspectRatio = sourceHeight / sourceWidth | |
| 143 | + targetHeight = Math.max( | |
| 144 | + 1, | |
| 145 | + Math.round(options.targetHeightDots || (targetWidth * aspectRatio)) | |
| 146 | + ) | |
| 147 | + } | |
| 137 | 148 | const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD |
| 149 | + const useBilinear = options.bilinearImageScale === true | |
| 138 | 150 | |
| 139 | - const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, true) | |
| 151 | + const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, useBilinear) | |
| 140 | 152 | const rasterPixels: number[] = new Array(targetWidth * targetHeight) |
| 141 | 153 | |
| 142 | 154 | for (let y = 0; y < targetHeight; y++) { |
| ... | ... | @@ -154,6 +166,13 @@ export async function rasterizeImageForPrinter ( |
| 154 | 166 | } |
| 155 | 167 | } |
| 156 | 168 | |
| 169 | + const clearTop = Math.max(0, Math.min(8, Math.floor(Number(options.clearTopRasterRows) || 0))) | |
| 170 | + for (let row = 0; row < clearTop && row < targetHeight; row++) { | |
| 171 | + for (let x = 0; x < targetWidth; x++) { | |
| 172 | + rasterPixels[row * targetWidth + x] = 0 | |
| 173 | + } | |
| 174 | + } | |
| 175 | + | |
| 157 | 176 | try { |
| 158 | 177 | if (scaledBitmap !== sourceBitmap && sourceBitmap?.recycle) sourceBitmap.recycle() |
| 159 | 178 | } catch (_) {} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
| 1 | +import { blePairRequiresWriteNoResponse } from '../bleWriteModeRules' | |
| 1 | 2 | import { |
| 2 | 3 | clearPrinter, |
| 3 | 4 | getBluetoothConnection, |
| ... | ... | @@ -11,10 +12,12 @@ import classicBluetooth from '../bluetoothTool.js' |
| 11 | 12 | import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster' |
| 12 | 13 | import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder' |
| 13 | 14 | import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol' |
| 15 | +import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload' | |
| 14 | 16 | import { |
| 15 | 17 | connectNativeFastPrinter as connectNativeFastPrinterPlugin, |
| 16 | 18 | disconnectNativeFastPrinter as disconnectNativeFastPrinterPlugin, |
| 17 | 19 | isNativeFastPrinterAvailable, |
| 20 | + printNativeFastFromLabelPrintJob, | |
| 18 | 21 | printNativeFastTemplate as printNativeFastTemplatePlugin, |
| 19 | 22 | } from '../nativeFastPrinter' |
| 20 | 23 | import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' |
| ... | ... | @@ -107,7 +110,15 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 107 | 110 | }) |
| 108 | 111 | } |
| 109 | 112 | |
| 110 | -function findBleWriteCharacteristic (deviceId: string): Promise<{ serviceId: string; characteristicId: string } | null> { | |
| 113 | +/** | |
| 114 | + * 优先带响应 write(与 uni 默认写入方式一致);仅当没有 write 再用 writeNoResponse(需在下发时传 writeType)。 | |
| 115 | + * 若反选 writeNoResponse 优先,易出现 writeBLECharacteristicValue:fail property not support。 | |
| 116 | + */ | |
| 117 | +function findBleWriteCharacteristic (deviceId: string): Promise<{ | |
| 118 | + serviceId: string | |
| 119 | + characteristicId: string | |
| 120 | + bleWriteUsesNoResponse: boolean | |
| 121 | +} | null> { | |
| 111 | 122 | return new Promise((resolve) => { |
| 112 | 123 | uni.getBLEDeviceServices({ |
| 113 | 124 | deviceId, |
| ... | ... | @@ -123,11 +134,42 @@ function findBleWriteCharacteristic (deviceId: string): Promise<{ serviceId: str |
| 123 | 134 | deviceId, |
| 124 | 135 | serviceId, |
| 125 | 136 | success: (charRes) => { |
| 126 | - const target = (charRes.characteristics || []).find((item: any) => item.properties && item.properties.write) | |
| 137 | + const chars = charRes.characteristics || [] | |
| 138 | + const hasWrite = (item: any) => { | |
| 139 | + const w = item.properties?.write | |
| 140 | + return w === true || w === 'true' | |
| 141 | + } | |
| 142 | + const hasWriteNoResp = (item: any) => { | |
| 143 | + const p = item.properties || {} | |
| 144 | + return ( | |
| 145 | + p.writeNoResponse === true || | |
| 146 | + p.writeNoResponse === 'true' || | |
| 147 | + p.writeWithoutResponse === true || | |
| 148 | + p.writeWithoutResponse === 'true' | |
| 149 | + ) | |
| 150 | + } | |
| 151 | + const writable = (item: any) => hasWrite(item) || hasWriteNoResp(item) | |
| 152 | + for (const item of chars) { | |
| 153 | + const cid = String(item.uuid || '') | |
| 154 | + if (blePairRequiresWriteNoResponse(serviceId, cid) && writable(item)) { | |
| 155 | + resolve({ | |
| 156 | + serviceId, | |
| 157 | + characteristicId: cid, | |
| 158 | + bleWriteUsesNoResponse: true, | |
| 159 | + }) | |
| 160 | + return | |
| 161 | + } | |
| 162 | + } | |
| 163 | + const withResp = chars.find(hasWrite) | |
| 164 | + const noResp = chars.find(hasWriteNoResp) | |
| 165 | + const target = withResp || noResp | |
| 127 | 166 | if (target) { |
| 167 | + const cid = String(target.uuid || '') | |
| 168 | + const forceNoResp = blePairRequiresWriteNoResponse(serviceId, cid) | |
| 128 | 169 | resolve({ |
| 129 | 170 | serviceId, |
| 130 | - characteristicId: target.uuid, | |
| 171 | + characteristicId: cid, | |
| 172 | + bleWriteUsesNoResponse: forceNoResp || (!withResp && !!noResp), | |
| 131 | 173 | }) |
| 132 | 174 | return |
| 133 | 175 | } |
| ... | ... | @@ -182,6 +224,7 @@ function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Pr |
| 182 | 224 | deviceType: 'ble', |
| 183 | 225 | mtu: negotiatedMtu, |
| 184 | 226 | driverKey: driver.key, |
| 227 | + bleWriteUsesNoResponse: write.bleWriteUsesNoResponse, | |
| 185 | 228 | }) |
| 186 | 229 | } |
| 187 | 230 | |
| ... | ... | @@ -295,12 +338,58 @@ function canUseNativeFastTemplatePrint (driver: PrinterDriver): boolean { |
| 295 | 338 | && isNativeFastPrinterAvailable() |
| 296 | 339 | } |
| 297 | 340 | |
| 341 | +/** 预览/业务侧:是否可走 native-fast-printer 的 printTemplate(templateJson + dataJson) */ | |
| 342 | +export function canPrintCurrentLabelViaNativeFastJob (): boolean { | |
| 343 | + return canUseNativeFastTemplatePrint(getCurrentPrinterDriver()) | |
| 344 | +} | |
| 345 | + | |
| 346 | +/** | |
| 347 | + * 将 buildLabelPrintJobPayload / getLastLabelPrintJobPayload 同构数据送入原生 printTemplate; | |
| 348 | + * 与 JSON.stringify(payload.template) + JSON.stringify(payload.printInputJson) 一致。 | |
| 349 | + */ | |
| 350 | +export async function printLabelPrintJobPayloadForCurrentPrinter ( | |
| 351 | + payload: LabelPrintJobPayload, | |
| 352 | + options: { printQty?: number } = {}, | |
| 353 | + onProgress?: (percent: number) => void | |
| 354 | +): Promise<PrinterDriver> { | |
| 355 | + const driver = getCurrentPrinterDriver() | |
| 356 | + const connection = getBluetoothConnection() | |
| 357 | + if ( | |
| 358 | + driver.protocol === 'tsc' | |
| 359 | + && connection?.deviceType === 'classic' | |
| 360 | + && connection?.transportMode === 'native-plugin' | |
| 361 | + && !isNativeFastPrinterAvailable() | |
| 362 | + ) { | |
| 363 | + throw new Error('NATIVE_FAST_PRINTER_PLUGIN_NOT_FOUND. Please rebuild the custom base with native-fast-printer.') | |
| 364 | + } | |
| 365 | + if (!canUseNativeFastTemplatePrint(driver)) { | |
| 366 | + throw new Error('Native fast template print is not available for the current printer.') | |
| 367 | + } | |
| 368 | + const nativeConnection = getNativeClassicConnection() | |
| 369 | + if (!nativeConnection) { | |
| 370 | + throw new Error('Native classic Bluetooth connection is not ready.') | |
| 371 | + } | |
| 372 | + const printQty = Math.max(1, options.printQty ?? payload.meta?.printQuantity ?? 1) | |
| 373 | + await printNativeFastFromLabelPrintJob({ | |
| 374 | + deviceId: nativeConnection.deviceId, | |
| 375 | + deviceName: nativeConnection.deviceName, | |
| 376 | + payload, | |
| 377 | + dpi: driver.imageDpi || 203, | |
| 378 | + printQty, | |
| 379 | + }) | |
| 380 | + if (onProgress) onProgress(100) | |
| 381 | + return driver | |
| 382 | +} | |
| 383 | + | |
| 298 | 384 | function getNativeClassicConnection () { |
| 299 | 385 | const connection = getBluetoothConnection() |
| 300 | 386 | if (!connection || connection.deviceType !== 'classic' || connection.transportMode !== 'native-plugin') return null |
| 301 | 387 | return connection |
| 302 | 388 | } |
| 303 | 389 | |
| 390 | +/** | |
| 391 | + * 连接自检用测试页;**不要**在此处调用 `postUsAppLabelPrint` / 接口 9(仅预览页业务打印落库)。 | |
| 392 | + */ | |
| 304 | 393 | export async function testPrintCurrentPrinter (onProgress?: (percent: number) => void): Promise<PrinterDriver> { |
| 305 | 394 | const driver = getCurrentPrinterDriver() |
| 306 | 395 | const connection = getBluetoothConnection() | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
| ... | ... | @@ -126,25 +126,47 @@ function splitTextLines (text: string, paint: any, maxWidth: number): string[] { |
| 126 | 126 | const lines: string[] = [] |
| 127 | 127 | const rawLines = String(text || '').replace(/\r/g, '').split('\n') |
| 128 | 128 | |
| 129 | + const pushLongWordByChars = (word: string) => { | |
| 130 | + let buf = '' | |
| 131 | + for (let i = 0; i < word.length; i++) { | |
| 132 | + const ch = word.charAt(i) | |
| 133 | + const trial = buf + ch | |
| 134 | + if (buf && Number(paint.measureText(trial)) > maxWidth) { | |
| 135 | + lines.push(buf) | |
| 136 | + buf = ch | |
| 137 | + } else { | |
| 138 | + buf = trial | |
| 139 | + } | |
| 140 | + } | |
| 141 | + return buf | |
| 142 | + } | |
| 143 | + | |
| 129 | 144 | rawLines.forEach((segment) => { |
| 130 | 145 | if (!segment) { |
| 131 | 146 | lines.push('') |
| 132 | 147 | return |
| 133 | 148 | } |
| 149 | + if (!segment.trim()) { | |
| 150 | + lines.push(segment) | |
| 151 | + return | |
| 152 | + } | |
| 134 | 153 | |
| 154 | + const words = segment.trim().split(/\s+/) | |
| 135 | 155 | let current = '' |
| 136 | - for (let i = 0; i < segment.length; i++) { | |
| 137 | - const char = segment.charAt(i) | |
| 138 | - const candidate = current + char | |
| 139 | - const measure = Number(paint.measureText(candidate)) | |
| 140 | - if (current && measure > maxWidth) { | |
| 141 | - lines.push(current) | |
| 142 | - current = char | |
| 156 | + for (const word of words) { | |
| 157 | + const trial = current ? `${current} ${word}` : word | |
| 158 | + if (Number(paint.measureText(trial)) <= maxWidth) { | |
| 159 | + current = trial | |
| 143 | 160 | } else { |
| 144 | - current = candidate | |
| 161 | + if (current) lines.push(current) | |
| 162 | + if (Number(paint.measureText(word)) <= maxWidth) { | |
| 163 | + current = word | |
| 164 | + } else { | |
| 165 | + current = pushLongWordByChars(word) | |
| 166 | + } | |
| 145 | 167 | } |
| 146 | 168 | } |
| 147 | - if (current || lines.length === 0) lines.push(current) | |
| 169 | + if (current) lines.push(current) | |
| 148 | 170 | }) |
| 149 | 171 | |
| 150 | 172 | return lines.length > 0 ? lines : [''] | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts
| 1 | +import type { LabelPrintJobPayload } from '../labelPreview/buildLabelPrintPayload' | |
| 1 | 2 | import type { LabelTemplateData, SystemLabelTemplate } from './types/printer' |
| 2 | 3 | |
| 3 | 4 | type NativePrinterResult = { |
| ... | ... | @@ -221,6 +222,53 @@ export function disconnectNativeFastPrinter () { |
| 221 | 222 | }) |
| 222 | 223 | } |
| 223 | 224 | |
| 225 | +/** | |
| 226 | + * 与 setLastLabelPrintJobPayload / getLastLabelPrintJobPayload 同构: | |
| 227 | + * templateJson、dataJson 分别 JSON.stringify(template)、JSON.stringify(printInputJson),与平台导出的 label-template JSON 对齐。 | |
| 228 | + * | |
| 229 | + * 注意:Android 插件在任务入队后即回调成功,真正写机在 PRINT_EXECUTOR 后台执行; | |
| 230 | + * 若 SIZE 超限、构建异常等,JS 仍可能已 resolve,需结合 getNativeFastPrinterDebugInfo 或改原生回调时机排查。 | |
| 231 | + */ | |
| 232 | +export function printNativeFastFromLabelPrintJob (options: { | |
| 233 | + deviceId: string | |
| 234 | + deviceName?: string | |
| 235 | + payload: LabelPrintJobPayload | |
| 236 | + dpi?: number | |
| 237 | + printQty?: number | |
| 238 | +}) { | |
| 239 | + const qty = Math.max(1, options.printQty ?? options.payload.meta?.printQuantity ?? 1) | |
| 240 | + return wrapCallback('printTemplate', 20000, (resolve, reject) => { | |
| 241 | + try { | |
| 242 | + const nativePlugin = ensureNativePlugin() | |
| 243 | + if (typeof nativePlugin.printTemplate !== 'function') { | |
| 244 | + reject(new Error('NATIVE_FAST_PRINTER_PRINT_METHOD_NOT_FOUND')) | |
| 245 | + return | |
| 246 | + } | |
| 247 | + nativePlugin.printTemplate({ | |
| 248 | + deviceId: options.deviceId, | |
| 249 | + deviceName: options.deviceName || '', | |
| 250 | + templateJson: JSON.stringify(options.payload.template), | |
| 251 | + dataJson: JSON.stringify(options.payload.printInputJson ?? {}), | |
| 252 | + dpi: options.dpi || 203, | |
| 253 | + printQty: qty, | |
| 254 | + }, (raw: any) => { | |
| 255 | + const res = parsePluginResult(raw) | |
| 256 | + updateNativeState({ | |
| 257 | + ...res, | |
| 258 | + lastAction: 'printTemplate', | |
| 259 | + }) | |
| 260 | + if (res.code === 1 || res.success === true) { | |
| 261 | + resolve(res) | |
| 262 | + return | |
| 263 | + } | |
| 264 | + reject(new Error(res.msg || res.errMsg || 'NATIVE_FAST_PRINTER_PRINT_FAILED')) | |
| 265 | + }) | |
| 266 | + } catch (error: any) { | |
| 267 | + reject(error instanceof Error ? error : new Error(String(error || 'NATIVE_FAST_PRINTER_PRINT_FAILED'))) | |
| 268 | + } | |
| 269 | + }) | |
| 270 | +} | |
| 271 | + | |
| 224 | 272 | export function printNativeFastTemplate (options: { |
| 225 | 273 | deviceId: string |
| 226 | 274 | deviceName?: string | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
0 → 100644
| 1 | +/** | |
| 2 | + * Android NativeTemplateCommandBuilder 仅处理:TEXT_*、QRCODE、BARCODE、IMAGE、 | |
| 3 | + * 以及 border=line 的 BLANK;其余类型在原生路径下会被静默跳过(与画布预览不一致)。 | |
| 4 | + */ | |
| 5 | +import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer' | |
| 6 | + | |
| 7 | +function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { | |
| 8 | + const type = String(el.type || '').toUpperCase() | |
| 9 | + if (type.startsWith('TEXT_')) return true | |
| 10 | + if (type === 'QRCODE' || type === 'BARCODE' || type === 'IMAGE') return true | |
| 11 | + if (type === 'BLANK') return true | |
| 12 | + return false | |
| 13 | +} | |
| 14 | + | |
| 15 | +/** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */ | |
| 16 | +export function templateHasUnsupportedNativeFastElements (template: SystemLabelTemplate): boolean { | |
| 17 | + for (const el of template.elements || []) { | |
| 18 | + if (!isElementHandledByNativeFastPrinter(el)) return true | |
| 19 | + } | |
| 20 | + return false | |
| 21 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
| ... | ... | @@ -4,6 +4,8 @@ |
| 4 | 4 | import type { ActiveBtDeviceType, PrinterType } from './types/printer' |
| 5 | 5 | import classicBluetooth from './bluetoothTool.js' |
| 6 | 6 | import { getDeviceFingerprint } from '../deviceInfo' |
| 7 | +import { blePairRequiresWriteNoResponse } from './bleWriteModeRules' | |
| 8 | +import { getPrinterDriverByKey } from './manager/driverRegistry' | |
| 7 | 9 | |
| 8 | 10 | const STORAGE_PRINTER_TYPE = 'printerType' |
| 9 | 11 | const STORAGE_BT_DEVICE_ID = 'btDeviceId' |
| ... | ... | @@ -13,6 +15,8 @@ const STORAGE_BT_CHARACTERISTIC_ID = 'btCharacteristicId' |
| 13 | 15 | const STORAGE_BT_DEVICE_TYPE = 'btDeviceType' // 'ble' | 'classic' |
| 14 | 16 | const STORAGE_BT_TRANSPORT_MODE = 'btTransportMode' // 'native-plugin' | 'generic' |
| 15 | 17 | const STORAGE_BLE_MTU = 'bleMTU' |
| 18 | +/** '1' = 仅支持 writeNoResponse 的特征,需在 writeBLECharacteristicValue 里指定 writeType */ | |
| 19 | +const STORAGE_BLE_WRITE_NO_RESPONSE = 'bleWriteNoResponse' | |
| 16 | 20 | const STORAGE_BUILTIN_PORT = 'builtinPort' |
| 17 | 21 | const STORAGE_PRINTER_DRIVER_KEY = 'printerDriverKey' |
| 18 | 22 | |
| ... | ... | @@ -32,6 +36,7 @@ export const PrinterStorageKeys = { |
| 32 | 36 | btDeviceType: STORAGE_BT_DEVICE_TYPE, |
| 33 | 37 | btTransportMode: STORAGE_BT_TRANSPORT_MODE, |
| 34 | 38 | bleMTU: STORAGE_BLE_MTU, |
| 39 | + bleWriteNoResponse: STORAGE_BLE_WRITE_NO_RESPONSE, | |
| 35 | 40 | driverKey: STORAGE_PRINTER_DRIVER_KEY, |
| 36 | 41 | } as const |
| 37 | 42 | |
| ... | ... | @@ -48,6 +53,8 @@ export function setBluetoothConnection (info: { |
| 48 | 53 | transportMode?: 'native-plugin' | 'generic' |
| 49 | 54 | mtu?: number |
| 50 | 55 | driverKey?: string |
| 56 | + /** 当前特征是否必须走 writeNoResponse(仅 write 为 false 时) */ | |
| 57 | + bleWriteUsesNoResponse?: boolean | |
| 51 | 58 | }) { |
| 52 | 59 | uni.setStorageSync(STORAGE_PRINTER_TYPE, 'bluetooth') |
| 53 | 60 | uni.setStorageSync(STORAGE_BT_DEVICE_ID, info.deviceId) |
| ... | ... | @@ -61,6 +68,11 @@ export function setBluetoothConnection (info: { |
| 61 | 68 | ) |
| 62 | 69 | uni.setStorageSync(STORAGE_BLE_MTU, info.mtu != null ? info.mtu : BLE_MTU_DEFAULT) |
| 63 | 70 | uni.setStorageSync(STORAGE_PRINTER_DRIVER_KEY, info.driverKey || '') |
| 71 | + if (info.deviceType === 'ble' || !info.deviceType) { | |
| 72 | + uni.setStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE, info.bleWriteUsesNoResponse ? '1' : '0') | |
| 73 | + } else { | |
| 74 | + uni.setStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE, '0') | |
| 75 | + } | |
| 64 | 76 | } |
| 65 | 77 | |
| 66 | 78 | export function setBuiltinPrinter (driverKey = 'generic-tsc') { |
| ... | ... | @@ -77,6 +89,7 @@ export function clearPrinter () { |
| 77 | 89 | uni.removeStorageSync(STORAGE_BT_DEVICE_TYPE) |
| 78 | 90 | uni.removeStorageSync(STORAGE_BT_TRANSPORT_MODE) |
| 79 | 91 | uni.removeStorageSync(STORAGE_BLE_MTU) |
| 92 | + uni.removeStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE) | |
| 80 | 93 | uni.removeStorageSync(STORAGE_BUILTIN_PORT) |
| 81 | 94 | uni.removeStorageSync(STORAGE_PRINTER_DRIVER_KEY) |
| 82 | 95 | } |
| ... | ... | @@ -103,6 +116,7 @@ export function getBluetoothConnection (): { |
| 103 | 116 | deviceType: BtDeviceType |
| 104 | 117 | transportMode: 'native-plugin' | 'generic' |
| 105 | 118 | mtu: number |
| 119 | + bleWriteUsesNoResponse: boolean | |
| 106 | 120 | } | null { |
| 107 | 121 | const deviceId = uni.getStorageSync(STORAGE_BT_DEVICE_ID) |
| 108 | 122 | const deviceType = (uni.getStorageSync(STORAGE_BT_DEVICE_TYPE) as BtDeviceType) || 'ble' |
| ... | ... | @@ -117,6 +131,7 @@ export function getBluetoothConnection (): { |
| 117 | 131 | deviceType: 'classic', |
| 118 | 132 | transportMode, |
| 119 | 133 | mtu: BLE_MTU_DEFAULT, |
| 134 | + bleWriteUsesNoResponse: false, | |
| 120 | 135 | } |
| 121 | 136 | } |
| 122 | 137 | const serviceId = uni.getStorageSync(STORAGE_BT_SERVICE_ID) |
| ... | ... | @@ -130,6 +145,7 @@ export function getBluetoothConnection (): { |
| 130 | 145 | deviceType: 'ble', |
| 131 | 146 | transportMode, |
| 132 | 147 | mtu: Number(uni.getStorageSync(STORAGE_BLE_MTU)) || BLE_MTU_DEFAULT, |
| 148 | + bleWriteUsesNoResponse: uni.getStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE) === '1', | |
| 133 | 149 | } |
| 134 | 150 | } |
| 135 | 151 | |
| ... | ... | @@ -212,6 +228,93 @@ export function sendToPrinter ( |
| 212 | 228 | return Promise.reject(new Error('No printer connected. Please connect a Bluetooth or built-in printer first.')) |
| 213 | 229 | } |
| 214 | 230 | |
| 231 | +/** 与打印机页扫描/连接一致:未 open 适配器时 writeBLECharacteristicValue 报 fail not init */ | |
| 232 | +function bleOpenAdapter (): Promise<void> { | |
| 233 | + return new Promise((resolve, reject) => { | |
| 234 | + // #ifdef APP-PLUS | |
| 235 | + uni.openBluetoothAdapter({ | |
| 236 | + success: () => resolve(), | |
| 237 | + fail: (err: any) => { | |
| 238 | + const msg = String(err?.errMsg || '') | |
| 239 | + const code = err?.errCode | |
| 240 | + if (msg.includes('already') || code === 10001) resolve() | |
| 241 | + else reject(new Error(msg || 'openBluetoothAdapter failed')) | |
| 242 | + }, | |
| 243 | + }) | |
| 244 | + // #endif | |
| 245 | + // #ifndef APP-PLUS | |
| 246 | + resolve() | |
| 247 | + // #endif | |
| 248 | + }) | |
| 249 | +} | |
| 250 | + | |
| 251 | +/** 设置页 onUnmounted 可能已 closeBluetoothAdapter,需重新建链后才能写特征值 */ | |
| 252 | +function bleEnsureDeviceConnected (deviceId: string): Promise<void> { | |
| 253 | + return new Promise((resolve, reject) => { | |
| 254 | + // #ifdef APP-PLUS | |
| 255 | + uni.createBLEConnection({ | |
| 256 | + deviceId, | |
| 257 | + timeout: 15000, | |
| 258 | + success: () => resolve(), | |
| 259 | + fail: (err: any) => { | |
| 260 | + const msg = String(err?.errMsg || '') | |
| 261 | + const code = err?.errCode | |
| 262 | + if ( | |
| 263 | + code === -1 || | |
| 264 | + msg.includes('already') || | |
| 265 | + msg.includes('Connected') || | |
| 266 | + msg.includes('connected') || | |
| 267 | + msg.includes('已连接') | |
| 268 | + ) { | |
| 269 | + resolve() | |
| 270 | + return | |
| 271 | + } | |
| 272 | + reject(new Error(msg || 'createBLEConnection failed')) | |
| 273 | + }, | |
| 274 | + }) | |
| 275 | + // #endif | |
| 276 | + // #ifndef APP-PLUS | |
| 277 | + resolve() | |
| 278 | + // #endif | |
| 279 | + }) | |
| 280 | +} | |
| 281 | + | |
| 282 | +/** | |
| 283 | + * 每次打印前会 createBLEConnection,链路 MTU 可能回到默认 23;若仍按 storage 里 512 分包,实机常丢数据但 write 仍 success。 | |
| 284 | + */ | |
| 285 | +function requestBleMtuNegotiation (deviceId: string, preferredMtu: number): Promise<number> { | |
| 286 | + return new Promise((resolve) => { | |
| 287 | + // #ifdef APP-PLUS | |
| 288 | + const targetMtu = Math.max(20, Math.min(512, Math.round(preferredMtu || 20))) | |
| 289 | + if (targetMtu <= 20 || typeof (uni as any).setBLEMTU !== 'function') { | |
| 290 | + resolve(20) | |
| 291 | + return | |
| 292 | + } | |
| 293 | + let settled = false | |
| 294 | + const done = (value: number) => { | |
| 295 | + if (settled) return | |
| 296 | + settled = true | |
| 297 | + clearTimeout(timer) | |
| 298 | + const m = Math.max(20, Math.round(value || 20)) | |
| 299 | + try { | |
| 300 | + uni.setStorageSync(STORAGE_BLE_MTU, m) | |
| 301 | + } catch (_) {} | |
| 302 | + resolve(m) | |
| 303 | + } | |
| 304 | + const timer = setTimeout(() => done(20), 3000) | |
| 305 | + ;(uni as any).setBLEMTU({ | |
| 306 | + deviceId, | |
| 307 | + mtu: targetMtu, | |
| 308 | + success: (res: any) => done(Number(res?.mtu) || targetMtu), | |
| 309 | + fail: () => done(20), | |
| 310 | + }) | |
| 311 | + // #endif | |
| 312 | + // #ifndef APP-PLUS | |
| 313 | + resolve(20) | |
| 314 | + // #endif | |
| 315 | + }) | |
| 316 | +} | |
| 317 | + | |
| 215 | 318 | function sendViaBle ( |
| 216 | 319 | data: number[], |
| 217 | 320 | onProgress?: (percent: number) => void |
| ... | ... | @@ -220,71 +323,205 @@ function sendViaBle ( |
| 220 | 323 | if (!conn) { |
| 221 | 324 | return Promise.reject(new Error('Bluetooth printer not connected.')) |
| 222 | 325 | } |
| 223 | - const { deviceId, serviceId, characteristicId, mtu } = conn | |
| 224 | - const payloadSize = Math.max(20, Math.round((mtu || BLE_MTU_DEFAULT) > 23 ? (mtu || BLE_MTU_DEFAULT) - 3 : (mtu || BLE_MTU_DEFAULT))) | |
| 225 | - const chunks: number[][] = [] | |
| 226 | - for (let i = 0; i < data.length; i += payloadSize) { | |
| 227 | - chunks.push(data.slice(i, i + payloadSize)) | |
| 228 | - } | |
| 229 | - const total = chunks.length | |
| 230 | - let sent = 0 | |
| 231 | - let completed = false | |
| 232 | - let timeoutId: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 0) | |
| 233 | - const writeDelayMs = payloadSize >= 180 ? 0 : (payloadSize > 20 ? 1 : 8) | |
| 234 | - | |
| 235 | - const resetTimeout = (reject: (reason?: any) => void) => { | |
| 236 | - if (timeoutId) clearTimeout(timeoutId) | |
| 237 | - timeoutId = setTimeout(() => { | |
| 238 | - if (completed) return | |
| 239 | - completed = true | |
| 240 | - reject(new Error('BLE write timeout')) | |
| 241 | - }, 15000) | |
| 242 | - } | |
| 326 | + const { deviceId, serviceId, characteristicId, bleWriteUsesNoResponse } = conn | |
| 243 | 327 | |
| 244 | - function sendNext (): Promise<void> { | |
| 245 | - if (completed) { | |
| 246 | - return Promise.reject(new Error('BLE write timeout')) | |
| 328 | + const runWritesWithPayloadSize = (payloadSize: number): Promise<void> => { | |
| 329 | + const chunks: number[][] = [] | |
| 330 | + for (let i = 0; i < data.length; i += payloadSize) { | |
| 331 | + chunks.push(data.slice(i, i + payloadSize)) | |
| 247 | 332 | } |
| 248 | - if (sent >= total) { | |
| 249 | - completed = true | |
| 250 | - if (timeoutId) clearTimeout(timeoutId) | |
| 251 | - if (onProgress) onProgress(100) | |
| 252 | - return Promise.resolve() | |
| 333 | + const total = chunks.length | |
| 334 | + let sent = 0 | |
| 335 | + let completed = false | |
| 336 | + let timeoutId: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 0) | |
| 337 | + const writeDelayMs = | |
| 338 | + payloadSize >= 180 ? 0 : payloadSize > 20 ? 2 : 10 | |
| 339 | + | |
| 340 | + /** 与 bleWriteModeRules 白名单一致:该 UUID 对只认 Write Command,不能切到「默认带响应写」 */ | |
| 341 | + const blePairForceNoResponse = blePairRequiresWriteNoResponse(serviceId, characteristicId) | |
| 342 | + | |
| 343 | + /** 本 job 内若因 property not support 翻过模式,后续包统一用 effectiveUseNoResp */ | |
| 344 | + let effectiveUseNoResp = bleWriteUsesNoResponse || blePairForceNoResponse | |
| 345 | + let hasFlippedWriteModeThisJob = false | |
| 346 | + let pendingPersistUseNoResp: boolean | null = null | |
| 347 | + | |
| 348 | + if (blePairForceNoResponse) { | |
| 349 | + try { | |
| 350 | + uni.setStorageSync(PrinterStorageKeys.bleWriteNoResponse, '1') | |
| 351 | + } catch (_) {} | |
| 253 | 352 | } |
| 254 | - const chunk = chunks[sent] | |
| 255 | - const buffer = new ArrayBuffer(chunk.length) | |
| 256 | - const view = new DataView(buffer) | |
| 257 | - for (let j = 0; j < chunk.length; j++) { | |
| 258 | - view.setUint8(j, chunk[j] & 0xff) | |
| 353 | + | |
| 354 | + const resetTimeout = (reject: (reason?: any) => void) => { | |
| 355 | + if (timeoutId) clearTimeout(timeoutId) | |
| 356 | + timeoutId = setTimeout(() => { | |
| 357 | + if (completed) return | |
| 358 | + completed = true | |
| 359 | + reject(new Error('BLE write timeout')) | |
| 360 | + }, Math.max(60000, total * 500)) | |
| 259 | 361 | } |
| 260 | - return new Promise((resolve, reject) => { | |
| 261 | - resetTimeout(reject) | |
| 262 | - uni.writeBLECharacteristicValue({ | |
| 263 | - deviceId, | |
| 362 | + | |
| 363 | + function logBleWriteFail (err: any, useNoResp: boolean, bufferLen: number) { | |
| 364 | + const msg = String(err?.errMsg ?? err?.message ?? '') | |
| 365 | + let errSerialized = '' | |
| 366 | + try { | |
| 367 | + errSerialized = JSON.stringify(err) | |
| 368 | + } catch { | |
| 369 | + errSerialized = String(err) | |
| 370 | + } | |
| 371 | + console.error('[sendViaBle] writeBLECharacteristicValue fail — 完整信息供真机调试复制', { | |
| 372 | + errMsg: msg, | |
| 373 | + errCode: err?.errCode, | |
| 374 | + errno: err?.errno, | |
| 375 | + code: err?.code, | |
| 376 | + writeTypeUsed: useNoResp ? 'writeNoResponse' : '(omitted, default write)', | |
| 377 | + effectiveUseNoResp, | |
| 378 | + bleWriteUsesNoResponseSaved: bleWriteUsesNoResponse, | |
| 379 | + bufferLen, | |
| 380 | + sentIndex: sent, | |
| 381 | + totalChunks: total, | |
| 264 | 382 | serviceId, |
| 265 | 383 | characteristicId, |
| 266 | - value: buffer, | |
| 267 | - success: () => { | |
| 268 | - if (completed) return | |
| 269 | - sent++ | |
| 270 | - if (onProgress) onProgress(Math.round((sent / total) * 100)) | |
| 271 | - if (writeDelayMs <= 0) { | |
| 272 | - sendNext().then(resolve).catch(reject) | |
| 273 | - return | |
| 384 | + deviceId, | |
| 385 | + rawErr: err, | |
| 386 | + errSerialized, | |
| 387 | + blePairForcedNoResponse: blePairForceNoResponse, | |
| 388 | + }) | |
| 389 | + } | |
| 390 | + | |
| 391 | + function writeOneBuffer (buffer: ArrayBuffer): Promise<void> { | |
| 392 | + return new Promise((resolve, reject) => { | |
| 393 | + const tryWrite = (useNoResp: boolean, allowFlip: boolean) => { | |
| 394 | + const opts: UniApp.WriteBLECharacteristicValueOption = { | |
| 395 | + deviceId, | |
| 396 | + serviceId, | |
| 397 | + characteristicId, | |
| 398 | + value: buffer, | |
| 274 | 399 | } |
| 275 | - setTimeout(() => sendNext().then(resolve).catch(reject), writeDelayMs) | |
| 276 | - }, | |
| 277 | - fail: (err: any) => { | |
| 278 | - if (completed) return | |
| 279 | - completed = true | |
| 280 | - if (timeoutId) clearTimeout(timeoutId) | |
| 281 | - reject(new Error(err.errMsg || 'BLE write failed')) | |
| 282 | - }, | |
| 400 | + /** 仅无响应写显式声明;带响应写不传 writeType,与 uni 历史默认一致,避免部分机型报 property not support */ | |
| 401 | + if (useNoResp) { | |
| 402 | + opts.writeType = 'writeNoResponse' | |
| 403 | + } | |
| 404 | + uni.writeBLECharacteristicValue({ | |
| 405 | + ...opts, | |
| 406 | + success: () => { | |
| 407 | + if (pendingPersistUseNoResp != null) { | |
| 408 | + try { | |
| 409 | + uni.setStorageSync( | |
| 410 | + PrinterStorageKeys.bleWriteNoResponse, | |
| 411 | + pendingPersistUseNoResp ? '1' : '0' | |
| 412 | + ) | |
| 413 | + } catch (_) {} | |
| 414 | + pendingPersistUseNoResp = null | |
| 415 | + } | |
| 416 | + resolve() | |
| 417 | + }, | |
| 418 | + fail: (err: any) => { | |
| 419 | + const msg = String(err?.errMsg ?? err?.message ?? '') | |
| 420 | + logBleWriteFail(err, useNoResp, buffer.byteLength) | |
| 421 | + const notSupport = | |
| 422 | + msg.includes('property not support') || | |
| 423 | + msg.includes('not support') || | |
| 424 | + String(err?.errCode) === '10007' | |
| 425 | + if (allowFlip && !hasFlippedWriteModeThisJob && notSupport) { | |
| 426 | + const nextUseNoResp = !useNoResp | |
| 427 | + /** | |
| 428 | + * 佳博等 Nordic 串口:GATT 虽声明 write,实测只接受 writeNoResponse。 | |
| 429 | + * 若 writeNoResponse 偶发失败后翻到「默认写」,首包可能仍 success,从第二包起必现 10007(打印量变长更易触发)。 | |
| 430 | + */ | |
| 431 | + if (blePairForceNoResponse && !nextUseNoResp) { | |
| 432 | + console.warn( | |
| 433 | + '[sendViaBle] 白名单串口禁止切到带响应写;请保持 writeNoResponse 或检查连接/MTU' | |
| 434 | + ) | |
| 435 | + reject(new Error(msg || 'BLE write failed')) | |
| 436 | + return | |
| 437 | + } | |
| 438 | + hasFlippedWriteModeThisJob = true | |
| 439 | + effectiveUseNoResp = nextUseNoResp | |
| 440 | + pendingPersistUseNoResp = effectiveUseNoResp | |
| 441 | + console.warn('[sendViaBle] property not support → 切换写入方式重试本包', { | |
| 442 | + nextMode: effectiveUseNoResp ? 'writeNoResponse' : 'defaultWrite(no writeType)', | |
| 443 | + }) | |
| 444 | + tryWrite(effectiveUseNoResp, false) | |
| 445 | + return | |
| 446 | + } | |
| 447 | + reject(new Error(msg || 'BLE write failed')) | |
| 448 | + }, | |
| 449 | + }) | |
| 450 | + } | |
| 451 | + tryWrite(effectiveUseNoResp, !hasFlippedWriteModeThisJob) | |
| 283 | 452 | }) |
| 284 | - }) | |
| 453 | + } | |
| 454 | + | |
| 455 | + function sendNext (): Promise<void> { | |
| 456 | + if (completed) { | |
| 457 | + return Promise.reject(new Error('BLE write timeout')) | |
| 458 | + } | |
| 459 | + if (sent >= total) { | |
| 460 | + completed = true | |
| 461 | + if (timeoutId) clearTimeout(timeoutId) | |
| 462 | + /** 末包 write 成功后立刻 resolve 时,部分机芯尚未吃完缓冲;短延迟再结束,减少「界面成功但不出纸」 */ | |
| 463 | + const settleMs = data.length > 400 ? 180 : 50 | |
| 464 | + return new Promise<void>((resolve) => { | |
| 465 | + setTimeout(() => { | |
| 466 | + if (onProgress) onProgress(100) | |
| 467 | + resolve() | |
| 468 | + }, settleMs) | |
| 469 | + }) | |
| 470 | + } | |
| 471 | + const chunk = chunks[sent] | |
| 472 | + const buffer = new ArrayBuffer(chunk.length) | |
| 473 | + const view = new DataView(buffer) | |
| 474 | + for (let j = 0; j < chunk.length; j++) { | |
| 475 | + view.setUint8(j, chunk[j] & 0xff) | |
| 476 | + } | |
| 477 | + return new Promise((resolve, reject) => { | |
| 478 | + resetTimeout(reject) | |
| 479 | + writeOneBuffer(buffer) | |
| 480 | + .then(() => { | |
| 481 | + if (completed) return | |
| 482 | + sent++ | |
| 483 | + if (onProgress) onProgress(Math.round((sent / total) * 100)) | |
| 484 | + if (writeDelayMs <= 0) { | |
| 485 | + sendNext().then(resolve).catch(reject) | |
| 486 | + return | |
| 487 | + } | |
| 488 | + setTimeout(() => sendNext().then(resolve).catch(reject), writeDelayMs) | |
| 489 | + }) | |
| 490 | + .catch((e: any) => { | |
| 491 | + if (completed) return | |
| 492 | + completed = true | |
| 493 | + if (timeoutId) clearTimeout(timeoutId) | |
| 494 | + reject(e instanceof Error ? e : new Error(String(e?.message || e || 'BLE write failed'))) | |
| 495 | + }) | |
| 496 | + }) | |
| 497 | + } | |
| 498 | + | |
| 499 | + return sendNext() | |
| 500 | + } | |
| 501 | + | |
| 502 | + /** | |
| 503 | + * 单包 ATT 可写字节数:理论为 mtu-3;MTU≤23 时旧代码误用 mtu 本值(如 23)会超过链路真上限 20,导致截断/无出纸却 write success。 | |
| 504 | + * 协商到 512 时不少佳博/安卓组合仍不能稳定传 500+ 字节/包,需再压到安全上限。 | |
| 505 | + */ | |
| 506 | + const mtuToPayloadSize = (negotiatedMtu: number) => { | |
| 507 | + const mtu = Math.max(23, Math.min(512, Math.round(negotiatedMtu || 23))) | |
| 508 | + const attPayload = Math.max(20, mtu - 3) | |
| 509 | + const SAFE_BLE_WRITE_CAP = 182 | |
| 510 | + return Math.min(attPayload, SAFE_BLE_WRITE_CAP) | |
| 285 | 511 | } |
| 286 | 512 | |
| 287 | - return sendNext() | |
| 513 | + // #ifdef APP-PLUS | |
| 514 | + if (conn.deviceType === 'ble') { | |
| 515 | + const driver = getPrinterDriverByKey(getCurrentPrinterDriverKey()) | |
| 516 | + const preferred = driver.preferredBleMtu || BLE_MTU_DEFAULT | |
| 517 | + return bleOpenAdapter() | |
| 518 | + .then(() => bleEnsureDeviceConnected(deviceId)) | |
| 519 | + .then(() => new Promise<void>((r) => setTimeout(r, 100))) | |
| 520 | + .then(() => requestBleMtuNegotiation(deviceId, preferred)) | |
| 521 | + .then((negotiated) => runWritesWithPayloadSize(mtuToPayloadSize(negotiated))) | |
| 522 | + } | |
| 523 | + // #endif | |
| 524 | + return runWritesWithPayloadSize(mtuToPayloadSize(conn.mtu || BLE_MTU_DEFAULT)) | |
| 288 | 525 | } |
| 289 | 526 | |
| 290 | 527 | function sendViaClassic ( | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/printerReadiness.ts
| ... | ... | @@ -13,8 +13,9 @@ export function isPrinterReadySync(): boolean { |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | /** |
| 16 | - * 检测系统蓝牙是否开启(APP 端)。H5 返回 false。 | |
| 17 | - * 用于在「未选打印机」时区分是否需先开蓝牙(仍统一用同一套文案弹窗)。 | |
| 16 | + * 检测 uni 蓝牙适配器状态(APP 端)。注意:若本页未调用过 openBluetoothAdapter, | |
| 17 | + * getBluetoothAdapterState 常返回 available: false,易误判;打印流程请以 isPrinterReadySync + 实际 write 结果为准。 | |
| 18 | + * H5 返回 false。 | |
| 18 | 19 | */ |
| 19 | 20 | export function checkBluetoothAdapterAvailable(): Promise<boolean> { |
| 20 | 21 | return new Promise((resolve) => { | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
0 → 100644
| 1 | +/** | |
| 2 | + * 与 NativeTemplateCommandBuilder.toMillimeter 一致(px 按 96dpi 转 mm), | |
| 3 | + * 用于判断模板是否适合走 native-fast-printer 的 TSC 模板指令(常见标签幅宽约 4 英寸级)。 | |
| 4 | + */ | |
| 5 | +import type { SystemLabelTemplate } from './types/printer' | |
| 6 | + | |
| 7 | +const DESIGN_DPI = 96 | |
| 8 | + | |
| 9 | +/** 常见 4″ 标签机安全上限(mm),略放宽 */ | |
| 10 | +const NATIVE_FAST_MAX_WIDTH_MM = 112 | |
| 11 | +const NATIVE_FAST_MAX_HEIGHT_MM = 320 | |
| 12 | + | |
| 13 | +export function templateSizeToMillimeters ( | |
| 14 | + unit: string | undefined, | |
| 15 | + width: number, | |
| 16 | + height: number | |
| 17 | +): { widthMm: number; heightMm: number } { | |
| 18 | + const u = String(unit || 'inch').toLowerCase() | |
| 19 | + let widthMm = 0 | |
| 20 | + let heightMm = 0 | |
| 21 | + if (u === 'mm') { | |
| 22 | + widthMm = width | |
| 23 | + heightMm = height | |
| 24 | + } else if (u === 'cm') { | |
| 25 | + widthMm = width * 10 | |
| 26 | + heightMm = height * 10 | |
| 27 | + } else if (u === 'px') { | |
| 28 | + widthMm = (width / DESIGN_DPI) * 25.4 | |
| 29 | + heightMm = (height / DESIGN_DPI) * 25.4 | |
| 30 | + } else { | |
| 31 | + widthMm = width * 25.4 | |
| 32 | + heightMm = height * 25.4 | |
| 33 | + } | |
| 34 | + return { widthMm, heightMm } | |
| 35 | +} | |
| 36 | + | |
| 37 | +export function isTemplateWithinNativeFastPrintBounds ( | |
| 38 | + template: Pick<SystemLabelTemplate, 'unit' | 'width' | 'height'> | |
| 39 | +): boolean { | |
| 40 | + const w = Number(template.width) || 0 | |
| 41 | + const h = Number(template.height) || 0 | |
| 42 | + if (w <= 0 || h <= 0) return false | |
| 43 | + const { widthMm, heightMm } = templateSizeToMillimeters(template.unit, w, h) | |
| 44 | + return widthMm <= NATIVE_FAST_MAX_WIDTH_MM && heightMm <= NATIVE_FAST_MAX_HEIGHT_MM | |
| 45 | +} | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
| ... | ... | @@ -30,6 +30,13 @@ export interface PrintImageOptions { |
| 30 | 30 | heightMm?: number |
| 31 | 31 | x?: number |
| 32 | 32 | y?: number |
| 33 | + /** 将光栅最上方若干行置白,减轻 canvas 缩放/二值化在首行产生的噪点横条 */ | |
| 34 | + clearTopRasterRows?: number | |
| 35 | + /** | |
| 36 | + * 解码 PNG 后缩放到目标点阵时是否双线性插值。标签线稿/文字建议 false(最近邻), | |
| 37 | + * 否则二值化后易出现竖向条纹、左右「错列」与预览不一致。 | |
| 38 | + */ | |
| 39 | + bilinearImageScale?: boolean | |
| 33 | 40 | } |
| 34 | 41 | |
| 35 | 42 | export interface MonochromeImageData { | ... | ... |
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs
| 1 | 1 | using System; |
| 2 | 2 | using System.Collections.Generic; |
| 3 | +using System.Text.Json; | |
| 3 | 4 | |
| 4 | 5 | namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling; |
| 5 | 6 | |
| ... | ... | @@ -40,6 +41,12 @@ public class UsAppLabelPrintInputVo |
| 40 | 41 | public Dictionary<string, object?>? PrintInputJson { get; set; } |
| 41 | 42 | |
| 42 | 43 | /// <summary> |
| 44 | + /// 可选:App 端合并后的完整模板快照(与平台导出 label-template JSON 同构,含 elements[].config)。 | |
| 45 | + /// 传入时明细表 RenderDataJson 优先存此快照,便于打印历史与出纸结果一致;未传时仍由服务端 PreviewAsync 生成。 | |
| 46 | + /// </summary> | |
| 47 | + public JsonElement? TemplateSnapshot { get; set; } | |
| 48 | + | |
| 49 | + /// <summary> | |
| 43 | 50 | /// 打印机Id(可选,若业务需要追踪) |
| 44 | 51 | /// </summary> |
| 45 | 52 | public string? PrinterId { get; set; } | ... | ... |
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
| ... | ... | @@ -355,19 +355,36 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ |
| 355 | 355 | throw new UserFriendlyException("该标签不属于当前门店"); |
| 356 | 356 | } |
| 357 | 357 | |
| 358 | - // 解析模板 elements(与预览一致的渲染数据) | |
| 359 | - var resolvedTemplate = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo | |
| 360 | - { | |
| 361 | - LabelCode = labelCode, | |
| 362 | - ProductId = input.ProductId?.Trim(), | |
| 363 | - BaseTime = input.BaseTime, | |
| 364 | - PrintInputJson = input.PrintInputJson | |
| 365 | - }); | |
| 366 | - | |
| 367 | 358 | var printInputJsonStr = input.PrintInputJson is null |
| 368 | 359 | ? null |
| 369 | 360 | : JsonSerializer.Serialize(input.PrintInputJson); |
| 370 | - var renderDataJsonStr = JsonSerializer.Serialize(resolvedTemplate); | |
| 361 | + | |
| 362 | + string renderDataJsonStr; | |
| 363 | + var snapshotOk = false; | |
| 364 | + if (input.TemplateSnapshot.HasValue) | |
| 365 | + { | |
| 366 | + var snapEl = input.TemplateSnapshot.Value; | |
| 367 | + if (snapEl.ValueKind == JsonValueKind.Object | |
| 368 | + && snapEl.TryGetProperty("elements", out var elArr) | |
| 369 | + && elArr.ValueKind == JsonValueKind.Array) | |
| 370 | + { | |
| 371 | + // App 与出纸一致的合并模板(label-template 同构),供打印历史/重打直接使用 | |
| 372 | + renderDataJsonStr = snapEl.GetRawText(); | |
| 373 | + snapshotOk = true; | |
| 374 | + } | |
| 375 | + } | |
| 376 | + | |
| 377 | + if (!snapshotOk) | |
| 378 | + { | |
| 379 | + var resolvedTemplate = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo | |
| 380 | + { | |
| 381 | + LabelCode = labelCode, | |
| 382 | + ProductId = input.ProductId?.Trim(), | |
| 383 | + BaseTime = input.BaseTime, | |
| 384 | + PrintInputJson = input.PrintInputJson | |
| 385 | + }); | |
| 386 | + renderDataJsonStr = JsonSerializer.Serialize(resolvedTemplate); | |
| 387 | + } | |
| 371 | 388 | |
| 372 | 389 | var now = DateTime.Now; |
| 373 | 390 | var currentUserId = CurrentUser?.Id?.ToString(); | ... | ... |
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
| ... | ... | @@ -15,6 +15,7 @@ import { |
| 15 | 15 | createDefaultElement, |
| 16 | 16 | labelElementsToApiPayload, |
| 17 | 17 | resolvedLibraryCategoryForPersist, |
| 18 | + resolvedValueSourceTypeForSave, | |
| 18 | 19 | valueSourceTypeForLibraryCategory, |
| 19 | 20 | } from '../../../types/labelTemplate'; |
| 20 | 21 | import type { LocationDto } from '../../../types/location'; | ... | ... |