Commit a001da6da1d4d31cba417d00a8e1fcb083435ab0

Authored by 杨鑫
1 parent ca4ab0f7

APP 预览打印

Showing 22 changed files with 1131 additions and 182 deletions
美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json
... ... @@ -2,8 +2,8 @@
2 2 "version" : "1.0",
3 3 "configurations" : [
4 4 {
5   - "customPlaygroundType" : "local",
6   - "playground" : "custom",
  5 + "customPlaygroundType" : "device",
  6 + "playground" : "standard",
7 7 "type" : "uni-app:app-android"
8 8 }
9 9 ]
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
... ... @@ -530,6 +530,7 @@ const handleUseBuiltin = () => {
530 530 }
531 531  
532 532 const testPrinting = ref(false)
  533 +/** 自检测试页,不落库接口 9(仅预览页业务打印成功后上报) */
533 534 const handleTestPrint = async () => {
534 535 if (testPrinting.value) return
535 536 testPrinting.value = true
... ...
美国版/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 &#39;../../components/LocationPicker.vue&#39;
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&lt;SystemLabelTemplate | null&gt;(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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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(() =&gt; {
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&lt;string, any&gt;): 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 &#39;../bluetoothTool.js&#39;
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&lt;{ 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 = &#39;btCharacteristicId&#39;
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
... ... @@ -279,6 +279,7 @@ export function buildTscImageLabel (
279 279 for (let i = 0; i < bitmapBytes.length; i++) out.push(bitmapBytes[i])
280 280 out.push(0x0d, 0x0a)
281 281 add(`PRINT 1,${printQty}`)
  282 + add('FEED 1')
282 283  
283 284 return out
284 285 }
... ...
美国版/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';
... ...