From a001da6da1d4d31cba417d00a8e1fcb083435ab0 Mon Sep 17 00:00:00 2001
From: jokerxue <2509699647@qq.com>
Date: Mon, 30 Mar 2026 16:00:55 +0800
Subject: [PATCH] APP 预览打印
---
美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json | 4 ++--
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue | 1 +
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue | 218 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------------------------------
美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue | 4 +++-
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts | 68 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts | 6 ++++++
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/buildLabelPrintPayload.ts | 110 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts | 190 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------------------
美国版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts | 27 +++++++++++++++++++++++++++
美国版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts | 31 +++++++++++++++++++++++++------
美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts | 95 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts | 40 +++++++++++++++++++++++++++++++---------
美国版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts | 48 ++++++++++++++++++++++++++++++++++++++++++++++++
美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts | 21 +++++++++++++++++++++
美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts | 347 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------------------------------------
美国版/Food Labeling Management App UniApp/src/utils/print/printerReadiness.ts | 5 +++--
美国版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts | 45 +++++++++++++++++++++++++++++++++++++++++++++
美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts | 1 +
美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts | 7 +++++++
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs | 7 +++++++
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs | 37 +++++++++++++++++++++++++++----------
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx | 1 +
22 files changed, 1131 insertions(+), 182 deletions(-)
create mode 100644 美国版/Food Labeling Management App UniApp/src/utils/labelPreview/buildLabelPrintPayload.ts
create mode 100644 美国版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts
create mode 100644 美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
create mode 100644 美国版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
diff --git a/美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json b/美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json
index 29998cf..07b129c 100644
--- a/美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json
+++ b/美国版/Food Labeling Management App UniApp/.hbuilderx/launch.json
@@ -2,8 +2,8 @@
"version" : "1.0",
"configurations" : [
{
- "customPlaygroundType" : "local",
- "playground" : "custom",
+ "customPlaygroundType" : "device",
+ "playground" : "standard",
"type" : "uni-app:app-android"
}
]
diff --git a/美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue b/美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
index 4928b79..009b940 100644
--- a/美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
+++ b/美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
@@ -530,6 +530,7 @@ const handleUseBuiltin = () => {
}
const testPrinting = ref(false)
+/** 自检测试页,不落库接口 9(仅预览页业务打印成功后上报) */
const handleTestPrint = async () => {
if (testPrinting.value) return
testPrinting.value = true
diff --git a/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue b/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
index 24eb366..88411ff 100644
--- a/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
+++ b/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
@@ -156,11 +156,14 @@
+
@@ -178,14 +181,16 @@ import LocationPicker from '../../components/LocationPicker.vue'
import NoPrinterModal from '../../components/NoPrinterModal.vue'
import { getStatusBarHeight } from '../../utils/statusBar'
import {
+ canPrintCurrentLabelViaNativeFastJob,
getCurrentPrinterSummary,
- printSystemTemplateForCurrentPrinter,
+ getCurrentPrinterDriver,
+ printImageForCurrentPrinter,
+ printLabelPrintJobPayloadForCurrentPrinter,
} from '../../utils/print/manager/printerManager'
import type { SystemLabelTemplate, SystemTemplateElementBase } from '../../utils/print/types/printer'
import { fetchLabelMultipleOptionById } from '../../services/labelMultipleOption'
import {
buildPrintInputJson,
- printInputJsonToLabelTemplateData,
ensureFreeFieldKeys,
isPrintInputFreeFieldElement,
isPrintInputOptionsElement,
@@ -196,8 +201,20 @@ import {
validatePrintInputFreeFieldsBeforePrint,
validatePrintInputOptionsBeforePrint,
} from '../../utils/labelPreview/printInputOptions'
+import {
+ buildLabelPrintJobPayload,
+ setLastLabelPrintJobPayload,
+} from '../../utils/labelPreview/buildLabelPrintPayload'
import { getCurrentStoreId } from '../../utils/stores'
-import { postUsAppLabelPreview, postUsAppLabelPrint } from '../../services/usAppLabeling'
+import {
+ buildApiUrl,
+} from '../../utils/apiBase'
+import {
+ buildUsAppLabelPrintRequestBody,
+ postUsAppLabelPreview,
+ postUsAppLabelPrint,
+ US_APP_LABEL_PRINT_PATH,
+} from '../../services/usAppLabeling'
import {
applyTemplateProductDefaultValuesToTemplate,
extractTemplateProductDefaultValuesFromPreviewPayload,
@@ -205,11 +222,15 @@ import {
overlayProductNameOnPreviewTemplate,
} from '../../utils/labelPreview/normalizePreviewTemplate'
import {
+ getLabelPrintRasterLayout,
getPreviewCanvasCssSize,
+ renderLabelPreviewCanvasToTempPathForPrint,
renderLabelPreviewToTempPath,
} from '../../utils/labelPreview/renderLabelPreviewCanvas'
-import { isPrinterReadySync, checkBluetoothAdapterAvailable } from '../../utils/print/printerReadiness'
-import { getBluetoothConnection, getPrinterType } from '../../utils/print/printerConnection'
+import { templateHasUnsupportedNativeFastElements } from '../../utils/print/nativeTemplateElementSupport'
+import { isTemplateWithinNativeFastPrintBounds } from '../../utils/print/templatePhysicalMm'
+import { isPrinterReadySync } from '../../utils/print/printerReadiness'
+import { getBluetoothConnection } from '../../utils/print/printerConnection'
import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
const statusBarHeight = getStatusBarHeight()
@@ -244,7 +265,6 @@ const basePreviewTemplate = ref(null)
const printOptionSelections = ref>({})
const dictLabelsByElementId = ref>({})
const dictValuesByElementId = ref>({})
-const printTaskId = ref('')
const printFreeFieldValues = ref>({})
let freeFieldPreviewTimer: ReturnType | null = null
@@ -364,9 +384,16 @@ const canvasCssW = ref(300)
const canvasCssH = ref(200)
onShow(() => {
+ btConnected.value = isPrinterReadySync()
const summary = getCurrentPrinterSummary()
- btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin'
- btDeviceName.value = summary.displayName || ''
+ const conn = getBluetoothConnection()
+ if (summary.type === 'bluetooth' && conn?.deviceName) {
+ btDeviceName.value = conn.deviceName
+ } else if (summary.type === 'builtin') {
+ btDeviceName.value = summary.driverName || 'Built-in'
+ } else {
+ btDeviceName.value = summary.displayName || ''
+ }
const name = uni.getStorageSync('storeName')
if (typeof name === 'string' && name.trim()) locationName.value = name.trim()
})
@@ -528,20 +555,6 @@ const handlePrint = async () => {
return
}
- if (getPrinterType() === 'bluetooth') {
- const adapterOk = await checkBluetoothAdapterAvailable()
- if (!adapterOk) {
- showNoPrinterModal.value = true
- return
- }
- }
-
- const loc = getCurrentStoreId()
- if (!loc) {
- uni.showToast({ title: 'No store selected.', icon: 'none' })
- return
- }
-
const tmplForValidate = basePreviewTemplate.value ?? systemTemplate.value
if (tmplForValidate) {
const optErr = validatePrintInputOptionsBeforePrint(
@@ -565,42 +578,135 @@ const handlePrint = async () => {
isPrinting.value = true
try {
- uni.showLoading({ title: 'Submitting print…', mask: true })
- const bt = getBluetoothConnection()
- /** 与当前画布一致:用合并后的模板(多选项 + 自由输入 + 平台默认值)组装 printInputJson,key 与控件 inputKey/elementName 对齐 */
+ uni.showLoading({ title: 'Rendering…', mask: true })
+ /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */
const mergedForPrint = computeMergedPreviewTemplate()
- const tmplForJson =
- mergedForPrint ?? basePreviewTemplate.value ?? systemTemplate.value ?? null
- const pj =
- tmplForJson != null
- ? buildPrintInputJson(
- tmplForJson,
- printOptionSelections.value,
- printFreeFieldValues.value
- )
- : {}
- const templateDataForPrinter = printInputJsonToLabelTemplateData(pj)
- const out = await postUsAppLabelPrint({
- locationId: loc,
+ const tmpl = mergedForPrint ?? systemTemplate.value
+ if (!tmpl || !instance) {
+ throw new Error('No label to print.')
+ }
+
+ const printInputJson = buildPrintInputJson(
+ tmpl,
+ printOptionSelections.value,
+ printFreeFieldValues.value
+ )
+ const labelPrintJobPayload = buildLabelPrintJobPayload(tmpl, printInputJson, {
labelCode: labelCode.value,
productId: productId.value || undefined,
printQuantity: printQty.value,
- printerMac: bt?.deviceId || undefined,
- printerAddress: bt?.deviceId || undefined,
- printInputJson: Object.keys(pj).length > 0 ? pj : undefined,
+ locationId: getCurrentStoreId() || undefined,
})
- printTaskId.value = out.taskId || ''
- if (printTaskId.value) labelIdDisplay.value = printTaskId.value
+ setLastLabelPrintJobPayload(labelPrintJobPayload)
+
+ /**
+ * 原生 printTemplate:① 物理尺寸超常见标签幅宽则回退光栅;② 含 WEIGHT/DATE/LOGO 等原生未实现类型时回退光栅(与画布一致,避免假成功)。
+ */
+ const useNativeTemplatePrint =
+ canPrintCurrentLabelViaNativeFastJob()
+ && isTemplateWithinNativeFastPrintBounds(tmpl)
+ && !templateHasUnsupportedNativeFastElements(tmpl)
+
+ if (useNativeTemplatePrint) {
+ /** 经典蓝牙 + native-fast-printer:template + printInputJson → 原生 printTemplate(仅物理尺寸在热敏标签可打范围内) */
+ uni.showLoading({ title: 'Printing…', mask: true })
+ await printLabelPrintJobPayloadForCurrentPrinter(
+ labelPrintJobPayload,
+ { printQty: printQty.value },
+ (percent) => {
+ if (percent > 5 && percent < 100) {
+ uni.showLoading({ title: `Printing ${percent}%`, mask: true })
+ }
+ }
+ )
+ } else {
+ const driver = getCurrentPrinterDriver()
+ const maxDots =
+ driver.imageMaxWidthDots || (driver.protocol === 'esc' ? 384 : 576)
+ const layout = getLabelPrintRasterLayout(tmpl, maxDots, driver.imageDpi || 203)
+
+ canvasCssW.value = layout.outW
+ canvasCssH.value = layout.outH
+ await nextTick()
+ await new Promise((r) => setTimeout(r, 50))
+
+ const tmpPath = await renderLabelPreviewCanvasToTempPathForPrint(
+ 'labelPreviewCanvas',
+ instance,
+ tmpl,
+ layout
+ )
+
+ uni.showLoading({ title: 'Printing…', mask: true })
+ /** 与历史验证路径一致:临时 PNG → 解码光栅 → TSC(避免预览页 canvasGetImageData 与打印机页行为不一致) */
+ await printImageForCurrentPrinter(
+ tmpPath,
+ {
+ printQty: printQty.value,
+ clearTopRasterRows: 1,
+ targetWidthDots: layout.outW,
+ targetHeightDots: layout.outH,
+ },
+ (percent) => {
+ if (percent > 5 && percent < 100) {
+ uni.showLoading({ title: `Printing ${percent}%`, mask: true })
+ }
+ }
+ )
+ }
+
+ /** 接口 9:仅本页业务标签出纸后落库(打印机设置/蓝牙 Test Print 不会执行此段) */
+ let printLogSyncFailed = false
+ let printLogRequestBody: ReturnType = null
+ try {
+ const bt = getBluetoothConnection()
+ printLogRequestBody = buildUsAppLabelPrintRequestBody({
+ locationId: getCurrentStoreId(),
+ labelCode: labelCode.value,
+ productId: productId.value || undefined,
+ printQuantity: printQty.value,
+ printInputJson,
+ templateSnapshot: labelPrintJobPayload.template,
+ printerMac: bt?.deviceId || undefined,
+ })
+ if (printLogRequestBody) {
+ await postUsAppLabelPrint(printLogRequestBody)
+ }
+ } catch (syncErr: unknown) {
+ if (!isUsAppSessionExpiredError(syncErr)) {
+ printLogSyncFailed = true
+ const msg = syncErr instanceof Error ? syncErr.message : String(syncErr)
+ console.error('[preview] 打印落库接口失败', {
+ 接口说明: 'App 打印(落库打印任务与明细)',
+ 接口路径: US_APP_LABEL_PRINT_PATH,
+ 完整URL: buildApiUrl(US_APP_LABEL_PRINT_PATH),
+ 方法: 'POST',
+ 请求体: printLogRequestBody,
+ 错误信息: msg,
+ 原始错误: syncErr,
+ })
+ }
+ /** 401 时 usAppApiRequest 已 Toast + 跳转,此处不再处理 */
+ }
+
+ const sz = getPreviewCanvasCssSize(tmpl, 720)
+ canvasCssW.value = sz.width
+ canvasCssH.value = sz.height
+ await nextTick()
+ try {
+ const path = await renderLabelPreviewToTempPath('labelPreviewCanvas', instance, tmpl, 720)
+ previewImageSrc.value = path
+ } catch {
+ /* 保持上一张预览图 */
+ }
- await printSystemTemplateForCurrentPrinter(
- mergedForPrint ?? systemTemplate.value,
- templateDataForPrinter,
- { printQty: printQty.value }
- )
uni.hideLoading()
+ const qty = printQty.value
+ const printedTitle = `${qty} label${qty > 1 ? 's' : ''} printed!`
uni.showToast({
- title: `${printQty.value} label${printQty.value > 1 ? 's' : ''} printed!`,
- icon: 'success',
+ title: printLogSyncFailed ? `${printedTitle} (log not saved)` : printedTitle,
+ icon: printLogSyncFailed ? 'none' : 'success',
+ duration: printLogSyncFailed ? 2800 : 2000,
})
} catch (e: any) {
uni.hideLoading()
@@ -620,6 +726,12 @@ const handlePrint = async () => {
uni.showToast({ title: msg, icon: 'none', duration: 3000 })
}
} finally {
+ const t = systemTemplate.value
+ if (t && instance) {
+ const sz = getPreviewCanvasCssSize(t, 720)
+ canvasCssW.value = sz.width
+ canvasCssH.value = sz.height
+ }
isPrinting.value = false
}
}
@@ -1090,10 +1202,10 @@ const handlePrint = async () => {
.hidden-canvas {
position: fixed;
- left: -2000px;
+ left: -9999px;
top: 0;
- width: 300px;
- height: 200px;
+ opacity: 0;
+ pointer-events: none;
}
.modal-mask {
diff --git a/美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue b/美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
index d99d5e5..8914521 100644
--- a/美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
+++ b/美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
@@ -412,6 +412,7 @@ const disconnect = async () => {
uni.showToast({ title: t('printers.disconnected') })
}
+/** 测试打印:仅下发位图,不调用 `/api/app/us-app-labeling/print`(接口 9 仅预览页出纸后落库) */
const doTestPrint = async () => {
try {
uni.showLoading({ title: 'Rendering canvas...', mask: true })
@@ -471,7 +472,8 @@ onUnmounted(() => {
}
} catch (_) {}
}
- uni.closeBluetoothAdapter({ complete: () => {} })
+ // 不在离开设置页时 closeBluetoothAdapter:否则标签预览等页面打印会 writeBLECharacteristicValue:fail not init(与 Test Print 同页才正常)。
+ // 需要释放蓝牙时请用户点「断开」或系统层关闭蓝牙。
})
diff --git a/美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts b/美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
index 7ae7cad..7277bec 100644
--- a/美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
+++ b/美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
@@ -9,6 +9,9 @@ import type {
} from '../types/usAppLabeling'
import { usAppApiRequest } from '../utils/usAppApiRequest'
+/** 接口 9:与文档路径一致,供日志与请求共用 */
+export const US_APP_LABEL_PRINT_PATH = '/api/app/us-app-labeling/print' as const
+
function asArr(v: unknown): unknown[] {
return Array.isArray(v) ? v : []
}
@@ -98,12 +101,73 @@ export async function postUsAppLabelPreview(body: UsAppLabelPreviewInputVo): Pro
})
}
-/** 接口 9.1 */
+/**
+ * 接口 9.1 原始请求。
+ * 注意:仅供业务落库场景调用;打印机设置页「测试打印」、蓝牙页 Test Print 等 **不得** 使用(避免脏数据)。
+ */
export async function postUsAppLabelPrint(body: UsAppLabelPrintInputVo): Promise {
return usAppApiRequest({
- path: '/api/app/us-app-labeling/print',
+ path: US_APP_LABEL_PRINT_PATH,
method: 'POST',
auth: true,
data: body,
})
}
+
+export function buildUsAppLabelPrintRequestBody(input: {
+ locationId?: string | null
+ labelCode?: string | null
+ productId?: string | null
+ printQuantity: number
+ printInputJson: Record
+ /** 与 buildLabelPrintJobPayload().template 同构,落库 RenderDataJson */
+ templateSnapshot?: Record | null
+ printerMac?: string | null
+ printerAddress?: string | null
+}): UsAppLabelPrintInputVo | null {
+ const locationId = String(input.locationId || '').trim()
+ const labelCode = String(input.labelCode || '').trim()
+ if (!locationId || !labelCode) return null
+
+ const body: UsAppLabelPrintInputVo = {
+ locationId,
+ labelCode,
+ printQuantity: Math.max(1, Math.round(Number(input.printQuantity) || 1)),
+ printInputJson: { ...input.printInputJson },
+ baseTime: new Date().toISOString(),
+ }
+ if (input.templateSnapshot && typeof input.templateSnapshot === 'object') {
+ try {
+ body.templateSnapshot = JSON.parse(JSON.stringify(input.templateSnapshot)) as Record
+ } catch {
+ /* 忽略快照克隆失败 */
+ }
+ }
+ const pid = String(input.productId || '').trim()
+ if (pid) body.productId = pid
+ const mac = String(input.printerMac || '').trim()
+ if (mac) body.printerMac = mac
+ const addr = String(input.printerAddress || '').trim()
+ if (addr) body.printerAddress = addr
+ return body
+}
+
+/**
+ * 接口 9:仅在 **标签预览页**(`pages/labels/preview`)用户打印真实标签、出纸成功后落库。
+ * `printInputJson` 与预览/原生 dataJson 同源;缺少 `locationId` 或 `labelCode` 时不发请求。
+ * 测试打印模板(printers / bluetooth Test Print)不走此函数。
+ */
+export async function reportUsAppLabelPrintIfReady(input: {
+ locationId?: string | null
+ labelCode?: string | null
+ productId?: string | null
+ printQuantity: number
+ printInputJson: Record
+ templateSnapshot?: Record | null
+ printerMac?: string | null
+ printerAddress?: string | null
+}): Promise {
+ const body = buildUsAppLabelPrintRequestBody(input)
+ if (!body) return null
+ return postUsAppLabelPrint(body)
+}
diff --git a/美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts b/美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
index 477224d..1761afc 100644
--- a/美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
+++ b/美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
@@ -62,7 +62,13 @@ export interface UsAppLabelPrintInputVo {
productId?: string
printQuantity?: number
baseTime?: string
+ /** 扁平 PRINT_INPUT 组装(审计/再合并用);与 App 内 buildPrintInputJson 一致 */
printInputJson?: Record
+ /**
+ * 与平台导出 label-template-*.json 同构的合并后模板(含 elements[].config);
+ * 服务端优先写入明细 RenderDataJson,便于打印历史按「整模板」重打,与出纸一致。
+ */
+ templateSnapshot?: Record
printerId?: string
printerMac?: string
printerAddress?: string
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/labelPreview/buildLabelPrintPayload.ts b/美国版/Food Labeling Management App UniApp/src/utils/labelPreview/buildLabelPrintPayload.ts
new file mode 100644
index 0000000..ad7e958
--- /dev/null
+++ b/美国版/Food Labeling Management App UniApp/src/utils/labelPreview/buildLabelPrintPayload.ts
@@ -0,0 +1,110 @@
+/**
+ * 按平台 label-template-*.json 结构组装「打印任务」数据(合并后的模板快照 + printInputJson)。
+ * 物理 BLE 打印仍走位图 TSC(与 Test Print 相同);本对象为业务/插件侧与 JSON 模板对齐的权威快照。
+ */
+import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
+
+/** 与仓库内 label-template-template-*.json 根结构一致 */
+export interface LabelTemplateDocumentJson {
+ id: string
+ name: string
+ labelType?: string
+ unit: string
+ width: number
+ height: number
+ appliedLocation?: string
+ showRuler?: boolean
+ showGrid?: boolean
+ elements: Record[]
+}
+
+export interface LabelPrintJobMeta {
+ labelCode?: string
+ productId?: string
+ printQuantity?: number
+ locationId?: string
+}
+
+export interface LabelPrintJobPayload {
+ template: LabelTemplateDocumentJson
+ /** 与接口 printInputJson 一致(inputKey / elementName 等键) */
+ printInputJson: Record
+ meta?: LabelPrintJobMeta
+}
+
+function cloneJsonSafeConfig(cfg: Record): Record {
+ try {
+ return JSON.parse(JSON.stringify(cfg ?? {})) as Record
+ } catch {
+ return { ...(cfg || {}) } as Record
+ }
+}
+
+/** 单元素序列化:与 JSON 模板 elements[] 项字段对齐,并保留 PRINT_INPUT 相关根字段 */
+export function serializeElementForLabelTemplateJson(el: SystemTemplateElementBase): Record {
+ const cfg = (el.config || {}) as Record
+ const o: Record = {
+ id: el.id,
+ type: el.type,
+ x: el.x,
+ y: el.y,
+ width: el.width,
+ height: el.height,
+ rotation: el.rotation ?? 'horizontal',
+ border: el.border ?? 'none',
+ config: cloneJsonSafeConfig(cfg),
+ }
+ if (el.valueSourceType) o.valueSourceType = el.valueSourceType
+ if (el.inputKey != null && String(el.inputKey).trim()) o.inputKey = el.inputKey
+ if (el.elementName != null && String(el.elementName).trim()) o.elementName = el.elementName
+ return o
+}
+
+/**
+ * 从当前合并模板 + 已组好的 printInputJson 生成与 label-template JSON 同构的打印载荷。
+ */
+export function buildLabelPrintJobPayload(
+ merged: SystemLabelTemplate,
+ printInputJson: Record,
+ meta?: LabelPrintJobMeta
+): LabelPrintJobPayload {
+ const template: LabelTemplateDocumentJson = {
+ id: String(merged.id || ''),
+ name: String(merged.name || '未命名模板'),
+ labelType: merged.labelType,
+ unit: String(merged.unit || 'inch'),
+ width: Number(merged.width) || 0,
+ height: Number(merged.height) || 0,
+ appliedLocation: merged.appliedLocation || 'ALL',
+ showRuler: merged.showRuler !== false,
+ showGrid: merged.showGrid !== false,
+ elements: (merged.elements || []).map(serializeElementForLabelTemplateJson),
+ }
+ return {
+ template,
+ printInputJson: { ...printInputJson },
+ meta: meta ? { ...meta } : undefined,
+ }
+}
+
+let lastLabelPrintJobPayload: LabelPrintJobPayload | null = null
+
+/** 供调试或后续原生插件读取最近一次组装的任务(template + printInputJson) */
+export function setLastLabelPrintJobPayload(p: LabelPrintJobPayload): void {
+ lastLabelPrintJobPayload = p
+}
+
+export function getLastLabelPrintJobPayload(): LabelPrintJobPayload | null {
+ return lastLabelPrintJobPayload
+}
+
+/** 与原生 printTemplate 入参一致:插件侧可对齐平台导出 JSON */
+export function stringifyLabelPrintJobForNative(p: LabelPrintJobPayload): {
+ templateJson: string
+ dataJson: string
+} {
+ return {
+ templateJson: JSON.stringify(p.template),
+ dataJson: JSON.stringify(p.printInputJson ?? {}),
+ }
+}
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts b/美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
index 36721c2..3405111 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
@@ -48,21 +48,60 @@ function readFillColor(config: Record): string {
function maxCharsPerLine(innerWidthPx: number, fontSize: number): number {
if (innerWidthPx <= 4) return 8
const approx = Math.max(0.45, Math.min(0.75, 0.55))
- return Math.max(4, Math.floor(innerWidthPx / (fontSize * approx)))
+ /** 下限 8:避免过窄估算时按 4 字硬切把英文单词拦腰截断(如 All|ergens) */
+ return Math.max(8, Math.floor(innerWidthPx / (fontSize * approx)))
}
-function wrapTextToWidth(text: string, maxChars: number): string[] {
- const lines = String(text).split(/\r?\n/)
+/**
+ * 优先在空格处断行,长词再按字符切分;避免固定宽度硬切破坏英文单词与「标签: 值」可读性。
+ */
+function wrapSingleLogicalLine(line: string, maxChars: number): string[] {
+ const limit = Math.max(8, maxChars)
+ const s = String(line)
+ if (s.length <= limit) return [s]
+
+ const words = s.split(/(\s+)/)
const out: string[] = []
- for (const line of lines) {
- if (line.length <= maxChars) {
- out.push(line)
+ let cur = ''
+
+ const pushLongToken = (token: string) => {
+ for (let i = 0; i < token.length; i += limit) {
+ out.push(token.slice(i, i + limit))
+ }
+ }
+
+ for (const w of words) {
+ if (/^\s+$/.test(w)) {
+ cur += w
continue
}
- for (let i = 0; i < line.length; i += maxChars) {
- out.push(line.slice(i, i + maxChars))
+ if (!w) continue
+
+ const trimmedRight = cur.replace(/\s+$/, '')
+ const candidate = trimmedRight ? `${trimmedRight} ${w}` : w
+ if (candidate.length <= limit) {
+ cur = candidate
+ } else {
+ if (trimmedRight) out.push(trimmedRight)
+ cur = ''
+ if (w.length > limit) {
+ pushLongToken(w)
+ } else {
+ cur = w
+ }
}
}
+ const tail = cur.replace(/\s+$/, '')
+ if (tail) out.push(tail)
+ return out.length ? out : ['']
+}
+
+function wrapTextToWidth(text: string, maxChars: number): string[] {
+ const lines = String(text).split(/\r?\n/)
+ const out: string[] = []
+ for (const line of lines) {
+ out.push(...wrapSingleLogicalLine(line, maxChars))
+ }
return out.length ? out : ['']
}
@@ -125,26 +164,18 @@ function previewExportPixelRatio(): number {
}
}
-/**
- * 将模板绘制到 canvas,并导出临时路径供 展示。
- */
-export function renderLabelPreviewToTempPath(
+/** 与屏幕预览 / 位图打印共用绘制逻辑(坐标系:设计宽 cw × ch,ctx 已 scale) */
+function runLabelPreviewCanvasDraw(
canvasId: string,
componentInstance: any,
template: SystemLabelTemplate,
- maxDisplayWidthPx = 720
-): Promise {
- const unit = template.unit || 'inch'
- const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit)))
- const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit)))
- const scale = Math.min(1, maxDisplayWidthPx / cw)
- const outW = Math.max(1, Math.round(cw * scale))
- const outH = Math.max(1, Math.round(ch * scale))
- const exportPr = previewExportPixelRatio()
-
+ cw: number,
+ ch: number,
+ scale: number
+): Promise {
const sorted = sortElementsForPreview(template.elements || [])
- return new Promise((resolve, reject) => {
+ return new Promise((resolve) => {
const ctx = uni.createCanvasContext(canvasId, componentInstance)
ctx.setFillStyle('#ffffff')
ctx.scale(scale, scale)
@@ -152,22 +183,7 @@ export function renderLabelPreviewToTempPath(
const drawRest = (index: number) => {
if (index >= sorted.length) {
- ctx.draw(false, () => {
- setTimeout(() => {
- uni.canvasToTempFilePath(
- {
- canvasId,
- width: outW,
- height: outH,
- destWidth: Math.round(outW * exportPr),
- destHeight: Math.round(outH * exportPr),
- success: (res) => resolve(res.tempFilePath),
- fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath failed')),
- },
- componentInstance
- )
- }, 120)
- })
+ ctx.draw(false, () => resolve())
return
}
@@ -281,6 +297,102 @@ export function renderLabelPreviewToTempPath(
})
}
+/**
+ * 将模板绘制到 canvas,并导出临时路径供 展示。
+ */
+export function renderLabelPreviewToTempPath(
+ canvasId: string,
+ componentInstance: any,
+ template: SystemLabelTemplate,
+ maxDisplayWidthPx = 720
+): Promise {
+ const unit = template.unit || 'inch'
+ const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit)))
+ const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit)))
+ const scale = Math.min(1, maxDisplayWidthPx / cw)
+ const outW = Math.max(1, Math.round(cw * scale))
+ const outH = Math.max(1, Math.round(ch * scale))
+ const exportPr = previewExportPixelRatio()
+
+ return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then(
+ () =>
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ uni.canvasToTempFilePath(
+ {
+ canvasId,
+ width: outW,
+ height: outH,
+ destWidth: Math.round(outW * exportPr),
+ destHeight: Math.round(outH * exportPr),
+ success: (res) => resolve(res.tempFilePath),
+ fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath failed')),
+ },
+ componentInstance
+ )
+ }, 120)
+ })
+ )
+}
+
+/**
+ * 打印专用:与屏幕预览相同走 canvasToTempFilePath(已验证能出图),再由 printImageForCurrentPrinter 用原生 Bitmap 解码光栅化。
+ * 避免 canvasGetImageData 在部分机型/页面上下文中返回空或错位,导致 BLE 发出“空标签”仍回调成功。
+ */
+export function renderLabelPreviewCanvasToTempPathForPrint(
+ canvasId: string,
+ componentInstance: any,
+ template: SystemLabelTemplate,
+ layout: { cw: number, ch: number, outW: number, outH: number, scale: number }
+): Promise {
+ const { cw, ch, outW, outH, scale } = layout
+ return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then(
+ () =>
+ new Promise((resolve, reject) => {
+ setTimeout(() => {
+ uni.canvasToTempFilePath(
+ {
+ canvasId,
+ x: 0,
+ y: 0,
+ width: outW,
+ height: outH,
+ destWidth: outW,
+ destHeight: outH,
+ fileType: 'png',
+ quality: 1,
+ success: (res) => resolve(res.tempFilePath),
+ fail: (err) => reject(new Error(err.errMsg || 'canvasToTempFilePath for print failed')),
+ },
+ componentInstance
+ )
+ }, 150)
+ })
+ )
+}
+
+/**
+ * 按打印机最大宽度(dots)与 DPI 计算栅格尺寸;宽为 8 的倍数,与 Test Print / rasterizeImageData 一致。
+ */
+export function getLabelPrintRasterLayout(
+ template: SystemLabelTemplate,
+ maxWidthDots: number,
+ printDpi = 203
+): { cw: number, ch: number, outW: number, outH: number, scale: number } {
+ const unit = template.unit || 'inch'
+ const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit)))
+ const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit)))
+ const designDpi = 96
+ const idealW = Math.round(cw * (printDpi / designDpi))
+ const cap = Math.max(8, Math.round(maxWidthDots || 576))
+ let outW = Math.max(8, Math.min(cap, idealW))
+ outW -= outW % 8
+ if (outW < 8) outW = 8
+ const scale = outW / cw
+ const outH = Math.max(1, Math.round(ch * scale))
+ return { cw, ch, outW, outH, scale }
+}
+
export function getPreviewCanvasCssSize(template: SystemLabelTemplate, maxDisplayWidthPx = 720): {
width: number
height: number
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts
new file mode 100644
index 0000000..44ce97b
--- /dev/null
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts
@@ -0,0 +1,27 @@
+/**
+ * 部分标签机 BLE 串口:GATT 同时声明 write / writeNoResponse,但数据口实际只接受 Write Command(无响应)。
+ * 用默认「带响应写」时常见首包过、第二包起 writeBLECharacteristicValue:fail property not support (10007)。
+ * printerConnection 对白名单 UUID 会强制全程 writeNoResponse,且禁止在 10007 时翻成「带响应写」(否则长任务/多份打印必挂)。
+ */
+
+export function normalizeBleUuid (uuid: string): string {
+ return String(uuid || '').replace(/-/g, '').toLowerCase()
+}
+
+/** serviceUuid + characteristicUuid(无横线小写) */
+const FORCE_WRITE_NO_RESPONSE: Array<{ service: string; characteristic: string }> = [
+ /** 佳博 GP-D320FX 等常见 Nordic UART 风格串口(与你机子日志一致) */
+ {
+ service: '49535343fe7d4ae58fa99fafd205e455',
+ characteristic: '49535343884143f4a8d4ecbe34729bb3',
+ },
+]
+
+export function blePairRequiresWriteNoResponse (
+ serviceId: string,
+ characteristicId: string
+): boolean {
+ const s = normalizeBleUuid(serviceId)
+ const c = normalizeBleUuid(characteristicId)
+ return FORCE_WRITE_NO_RESPONSE.some((p) => p.service === s && p.characteristic === c)
+}
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
index a2c9d37..026e2f3 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
@@ -129,14 +129,26 @@ export async function rasterizeImageForPrinter (
const protocolMaxWidth = getDefaultMaxWidthDots(driver)
const maxWidthDots = options.maxWidthDots && options.maxWidthDots > 0 ? options.maxWidthDots : protocolMaxWidth
const targetWidth = ensureMultipleOf8(options.targetWidthDots || sourceWidth, maxWidthDots)
- const aspectRatio = sourceHeight / sourceWidth
- const targetHeight = Math.max(
- 1,
- Math.round(options.targetHeightDots || (targetWidth * aspectRatio))
- )
+ const hasFixedTargetBox =
+ options.targetWidthDots != null &&
+ options.targetWidthDots > 0 &&
+ options.targetHeightDots != null &&
+ options.targetHeightDots > 0
+ let targetHeight: number
+ if (hasFixedTargetBox) {
+ /** 与 getLabelPrintRasterLayout 的 outW×outH 一致,避免 PNG 实际像素与画布布局偏差导致 TSC 错位 */
+ targetHeight = Math.max(1, Math.round(Number(options.targetHeightDots)))
+ } else {
+ const aspectRatio = sourceHeight / sourceWidth
+ targetHeight = Math.max(
+ 1,
+ Math.round(options.targetHeightDots || (targetWidth * aspectRatio))
+ )
+ }
const threshold = options.threshold != null ? Number(options.threshold) : DEFAULT_IMAGE_THRESHOLD
+ const useBilinear = options.bilinearImageScale === true
- const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, true)
+ const scaledBitmap = Bitmap.createScaledBitmap(sourceBitmap, targetWidth, targetHeight, useBilinear)
const rasterPixels: number[] = new Array(targetWidth * targetHeight)
for (let y = 0; y < targetHeight; y++) {
@@ -154,6 +166,13 @@ export async function rasterizeImageForPrinter (
}
}
+ const clearTop = Math.max(0, Math.min(8, Math.floor(Number(options.clearTopRasterRows) || 0)))
+ for (let row = 0; row < clearTop && row < targetHeight; row++) {
+ for (let x = 0; x < targetWidth; x++) {
+ rasterPixels[row * targetWidth + x] = 0
+ }
+ }
+
try {
if (scaledBitmap !== sourceBitmap && sourceBitmap?.recycle) sourceBitmap.recycle()
} catch (_) {}
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
index 2ba6218..2c0c69b 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
@@ -1,3 +1,4 @@
+import { blePairRequiresWriteNoResponse } from '../bleWriteModeRules'
import {
clearPrinter,
getBluetoothConnection,
@@ -11,10 +12,12 @@ import classicBluetooth from '../bluetoothTool.js'
import { rasterizeImageData, rasterizeImageForPrinter } from '../imageRaster'
import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder'
import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol'
+import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload'
import {
connectNativeFastPrinter as connectNativeFastPrinterPlugin,
disconnectNativeFastPrinter as disconnectNativeFastPrinterPlugin,
isNativeFastPrinterAvailable,
+ printNativeFastFromLabelPrintJob,
printNativeFastTemplate as printNativeFastTemplatePlugin,
} from '../nativeFastPrinter'
import { adaptSystemLabelTemplate } from '../systemTemplateAdapter'
@@ -107,7 +110,15 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive
})
}
-function findBleWriteCharacteristic (deviceId: string): Promise<{ serviceId: string; characteristicId: string } | null> {
+/**
+ * 优先带响应 write(与 uni 默认写入方式一致);仅当没有 write 再用 writeNoResponse(需在下发时传 writeType)。
+ * 若反选 writeNoResponse 优先,易出现 writeBLECharacteristicValue:fail property not support。
+ */
+function findBleWriteCharacteristic (deviceId: string): Promise<{
+ serviceId: string
+ characteristicId: string
+ bleWriteUsesNoResponse: boolean
+} | null> {
return new Promise((resolve) => {
uni.getBLEDeviceServices({
deviceId,
@@ -123,11 +134,42 @@ function findBleWriteCharacteristic (deviceId: string): Promise<{ serviceId: str
deviceId,
serviceId,
success: (charRes) => {
- const target = (charRes.characteristics || []).find((item: any) => item.properties && item.properties.write)
+ const chars = charRes.characteristics || []
+ const hasWrite = (item: any) => {
+ const w = item.properties?.write
+ return w === true || w === 'true'
+ }
+ const hasWriteNoResp = (item: any) => {
+ const p = item.properties || {}
+ return (
+ p.writeNoResponse === true ||
+ p.writeNoResponse === 'true' ||
+ p.writeWithoutResponse === true ||
+ p.writeWithoutResponse === 'true'
+ )
+ }
+ const writable = (item: any) => hasWrite(item) || hasWriteNoResp(item)
+ for (const item of chars) {
+ const cid = String(item.uuid || '')
+ if (blePairRequiresWriteNoResponse(serviceId, cid) && writable(item)) {
+ resolve({
+ serviceId,
+ characteristicId: cid,
+ bleWriteUsesNoResponse: true,
+ })
+ return
+ }
+ }
+ const withResp = chars.find(hasWrite)
+ const noResp = chars.find(hasWriteNoResp)
+ const target = withResp || noResp
if (target) {
+ const cid = String(target.uuid || '')
+ const forceNoResp = blePairRequiresWriteNoResponse(serviceId, cid)
resolve({
serviceId,
- characteristicId: target.uuid,
+ characteristicId: cid,
+ bleWriteUsesNoResponse: forceNoResp || (!withResp && !!noResp),
})
return
}
@@ -182,6 +224,7 @@ function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Pr
deviceType: 'ble',
mtu: negotiatedMtu,
driverKey: driver.key,
+ bleWriteUsesNoResponse: write.bleWriteUsesNoResponse,
})
}
@@ -295,12 +338,58 @@ function canUseNativeFastTemplatePrint (driver: PrinterDriver): boolean {
&& isNativeFastPrinterAvailable()
}
+/** 预览/业务侧:是否可走 native-fast-printer 的 printTemplate(templateJson + dataJson) */
+export function canPrintCurrentLabelViaNativeFastJob (): boolean {
+ return canUseNativeFastTemplatePrint(getCurrentPrinterDriver())
+}
+
+/**
+ * 将 buildLabelPrintJobPayload / getLastLabelPrintJobPayload 同构数据送入原生 printTemplate;
+ * 与 JSON.stringify(payload.template) + JSON.stringify(payload.printInputJson) 一致。
+ */
+export async function printLabelPrintJobPayloadForCurrentPrinter (
+ payload: LabelPrintJobPayload,
+ options: { printQty?: number } = {},
+ onProgress?: (percent: number) => void
+): Promise {
+ const driver = getCurrentPrinterDriver()
+ const connection = getBluetoothConnection()
+ if (
+ driver.protocol === 'tsc'
+ && connection?.deviceType === 'classic'
+ && connection?.transportMode === 'native-plugin'
+ && !isNativeFastPrinterAvailable()
+ ) {
+ throw new Error('NATIVE_FAST_PRINTER_PLUGIN_NOT_FOUND. Please rebuild the custom base with native-fast-printer.')
+ }
+ if (!canUseNativeFastTemplatePrint(driver)) {
+ throw new Error('Native fast template print is not available for the current printer.')
+ }
+ const nativeConnection = getNativeClassicConnection()
+ if (!nativeConnection) {
+ throw new Error('Native classic Bluetooth connection is not ready.')
+ }
+ const printQty = Math.max(1, options.printQty ?? payload.meta?.printQuantity ?? 1)
+ await printNativeFastFromLabelPrintJob({
+ deviceId: nativeConnection.deviceId,
+ deviceName: nativeConnection.deviceName,
+ payload,
+ dpi: driver.imageDpi || 203,
+ printQty,
+ })
+ if (onProgress) onProgress(100)
+ return driver
+}
+
function getNativeClassicConnection () {
const connection = getBluetoothConnection()
if (!connection || connection.deviceType !== 'classic' || connection.transportMode !== 'native-plugin') return null
return connection
}
+/**
+ * 连接自检用测试页;**不要**在此处调用 `postUsAppLabelPrint` / 接口 9(仅预览页业务打印落库)。
+ */
export async function testPrintCurrentPrinter (onProgress?: (percent: number) => void): Promise {
const driver = getCurrentPrinterDriver()
const connection = getBluetoothConnection()
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
index b566b52..e56cbea 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
@@ -126,25 +126,47 @@ function splitTextLines (text: string, paint: any, maxWidth: number): string[] {
const lines: string[] = []
const rawLines = String(text || '').replace(/\r/g, '').split('\n')
+ const pushLongWordByChars = (word: string) => {
+ let buf = ''
+ for (let i = 0; i < word.length; i++) {
+ const ch = word.charAt(i)
+ const trial = buf + ch
+ if (buf && Number(paint.measureText(trial)) > maxWidth) {
+ lines.push(buf)
+ buf = ch
+ } else {
+ buf = trial
+ }
+ }
+ return buf
+ }
+
rawLines.forEach((segment) => {
if (!segment) {
lines.push('')
return
}
+ if (!segment.trim()) {
+ lines.push(segment)
+ return
+ }
+ const words = segment.trim().split(/\s+/)
let current = ''
- for (let i = 0; i < segment.length; i++) {
- const char = segment.charAt(i)
- const candidate = current + char
- const measure = Number(paint.measureText(candidate))
- if (current && measure > maxWidth) {
- lines.push(current)
- current = char
+ for (const word of words) {
+ const trial = current ? `${current} ${word}` : word
+ if (Number(paint.measureText(trial)) <= maxWidth) {
+ current = trial
} else {
- current = candidate
+ if (current) lines.push(current)
+ if (Number(paint.measureText(word)) <= maxWidth) {
+ current = word
+ } else {
+ current = pushLongWordByChars(word)
+ }
}
}
- if (current || lines.length === 0) lines.push(current)
+ if (current) lines.push(current)
})
return lines.length > 0 ? lines : ['']
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts
index 1fe229f..3edc65b 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts
@@ -1,3 +1,4 @@
+import type { LabelPrintJobPayload } from '../labelPreview/buildLabelPrintPayload'
import type { LabelTemplateData, SystemLabelTemplate } from './types/printer'
type NativePrinterResult = {
@@ -221,6 +222,53 @@ export function disconnectNativeFastPrinter () {
})
}
+/**
+ * 与 setLastLabelPrintJobPayload / getLastLabelPrintJobPayload 同构:
+ * templateJson、dataJson 分别 JSON.stringify(template)、JSON.stringify(printInputJson),与平台导出的 label-template JSON 对齐。
+ *
+ * 注意:Android 插件在任务入队后即回调成功,真正写机在 PRINT_EXECUTOR 后台执行;
+ * 若 SIZE 超限、构建异常等,JS 仍可能已 resolve,需结合 getNativeFastPrinterDebugInfo 或改原生回调时机排查。
+ */
+export function printNativeFastFromLabelPrintJob (options: {
+ deviceId: string
+ deviceName?: string
+ payload: LabelPrintJobPayload
+ dpi?: number
+ printQty?: number
+}) {
+ const qty = Math.max(1, options.printQty ?? options.payload.meta?.printQuantity ?? 1)
+ return wrapCallback('printTemplate', 20000, (resolve, reject) => {
+ try {
+ const nativePlugin = ensureNativePlugin()
+ if (typeof nativePlugin.printTemplate !== 'function') {
+ reject(new Error('NATIVE_FAST_PRINTER_PRINT_METHOD_NOT_FOUND'))
+ return
+ }
+ nativePlugin.printTemplate({
+ deviceId: options.deviceId,
+ deviceName: options.deviceName || '',
+ templateJson: JSON.stringify(options.payload.template),
+ dataJson: JSON.stringify(options.payload.printInputJson ?? {}),
+ dpi: options.dpi || 203,
+ printQty: qty,
+ }, (raw: any) => {
+ const res = parsePluginResult(raw)
+ updateNativeState({
+ ...res,
+ lastAction: 'printTemplate',
+ })
+ if (res.code === 1 || res.success === true) {
+ resolve(res)
+ return
+ }
+ reject(new Error(res.msg || res.errMsg || 'NATIVE_FAST_PRINTER_PRINT_FAILED'))
+ })
+ } catch (error: any) {
+ reject(error instanceof Error ? error : new Error(String(error || 'NATIVE_FAST_PRINTER_PRINT_FAILED')))
+ }
+ })
+}
+
export function printNativeFastTemplate (options: {
deviceId: string
deviceName?: string
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
new file mode 100644
index 0000000..2cc51e7
--- /dev/null
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
@@ -0,0 +1,21 @@
+/**
+ * Android NativeTemplateCommandBuilder 仅处理:TEXT_*、QRCODE、BARCODE、IMAGE、
+ * 以及 border=line 的 BLANK;其余类型在原生路径下会被静默跳过(与画布预览不一致)。
+ */
+import type { SystemLabelTemplate, SystemTemplateElementBase } from './types/printer'
+
+function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean {
+ const type = String(el.type || '').toUpperCase()
+ if (type.startsWith('TEXT_')) return true
+ if (type === 'QRCODE' || type === 'BARCODE' || type === 'IMAGE') return true
+ if (type === 'BLANK') return true
+ return false
+}
+
+/** 存在任一原生不支持的元素时,预览打印应走光栅,避免「成功但缺内容/不出纸」与画布不一致 */
+export function templateHasUnsupportedNativeFastElements (template: SystemLabelTemplate): boolean {
+ for (const el of template.elements || []) {
+ if (!isElementHandledByNativeFastPrinter(el)) return true
+ }
+ return false
+}
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
index e7334df..2f26f6b 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
@@ -4,6 +4,8 @@
import type { ActiveBtDeviceType, PrinterType } from './types/printer'
import classicBluetooth from './bluetoothTool.js'
import { getDeviceFingerprint } from '../deviceInfo'
+import { blePairRequiresWriteNoResponse } from './bleWriteModeRules'
+import { getPrinterDriverByKey } from './manager/driverRegistry'
const STORAGE_PRINTER_TYPE = 'printerType'
const STORAGE_BT_DEVICE_ID = 'btDeviceId'
@@ -13,6 +15,8 @@ const STORAGE_BT_CHARACTERISTIC_ID = 'btCharacteristicId'
const STORAGE_BT_DEVICE_TYPE = 'btDeviceType' // 'ble' | 'classic'
const STORAGE_BT_TRANSPORT_MODE = 'btTransportMode' // 'native-plugin' | 'generic'
const STORAGE_BLE_MTU = 'bleMTU'
+/** '1' = 仅支持 writeNoResponse 的特征,需在 writeBLECharacteristicValue 里指定 writeType */
+const STORAGE_BLE_WRITE_NO_RESPONSE = 'bleWriteNoResponse'
const STORAGE_BUILTIN_PORT = 'builtinPort'
const STORAGE_PRINTER_DRIVER_KEY = 'printerDriverKey'
@@ -32,6 +36,7 @@ export const PrinterStorageKeys = {
btDeviceType: STORAGE_BT_DEVICE_TYPE,
btTransportMode: STORAGE_BT_TRANSPORT_MODE,
bleMTU: STORAGE_BLE_MTU,
+ bleWriteNoResponse: STORAGE_BLE_WRITE_NO_RESPONSE,
driverKey: STORAGE_PRINTER_DRIVER_KEY,
} as const
@@ -48,6 +53,8 @@ export function setBluetoothConnection (info: {
transportMode?: 'native-plugin' | 'generic'
mtu?: number
driverKey?: string
+ /** 当前特征是否必须走 writeNoResponse(仅 write 为 false 时) */
+ bleWriteUsesNoResponse?: boolean
}) {
uni.setStorageSync(STORAGE_PRINTER_TYPE, 'bluetooth')
uni.setStorageSync(STORAGE_BT_DEVICE_ID, info.deviceId)
@@ -61,6 +68,11 @@ export function setBluetoothConnection (info: {
)
uni.setStorageSync(STORAGE_BLE_MTU, info.mtu != null ? info.mtu : BLE_MTU_DEFAULT)
uni.setStorageSync(STORAGE_PRINTER_DRIVER_KEY, info.driverKey || '')
+ if (info.deviceType === 'ble' || !info.deviceType) {
+ uni.setStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE, info.bleWriteUsesNoResponse ? '1' : '0')
+ } else {
+ uni.setStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE, '0')
+ }
}
export function setBuiltinPrinter (driverKey = 'generic-tsc') {
@@ -77,6 +89,7 @@ export function clearPrinter () {
uni.removeStorageSync(STORAGE_BT_DEVICE_TYPE)
uni.removeStorageSync(STORAGE_BT_TRANSPORT_MODE)
uni.removeStorageSync(STORAGE_BLE_MTU)
+ uni.removeStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE)
uni.removeStorageSync(STORAGE_BUILTIN_PORT)
uni.removeStorageSync(STORAGE_PRINTER_DRIVER_KEY)
}
@@ -103,6 +116,7 @@ export function getBluetoothConnection (): {
deviceType: BtDeviceType
transportMode: 'native-plugin' | 'generic'
mtu: number
+ bleWriteUsesNoResponse: boolean
} | null {
const deviceId = uni.getStorageSync(STORAGE_BT_DEVICE_ID)
const deviceType = (uni.getStorageSync(STORAGE_BT_DEVICE_TYPE) as BtDeviceType) || 'ble'
@@ -117,6 +131,7 @@ export function getBluetoothConnection (): {
deviceType: 'classic',
transportMode,
mtu: BLE_MTU_DEFAULT,
+ bleWriteUsesNoResponse: false,
}
}
const serviceId = uni.getStorageSync(STORAGE_BT_SERVICE_ID)
@@ -130,6 +145,7 @@ export function getBluetoothConnection (): {
deviceType: 'ble',
transportMode,
mtu: Number(uni.getStorageSync(STORAGE_BLE_MTU)) || BLE_MTU_DEFAULT,
+ bleWriteUsesNoResponse: uni.getStorageSync(STORAGE_BLE_WRITE_NO_RESPONSE) === '1',
}
}
@@ -212,6 +228,93 @@ export function sendToPrinter (
return Promise.reject(new Error('No printer connected. Please connect a Bluetooth or built-in printer first.'))
}
+/** 与打印机页扫描/连接一致:未 open 适配器时 writeBLECharacteristicValue 报 fail not init */
+function bleOpenAdapter (): Promise {
+ return new Promise((resolve, reject) => {
+ // #ifdef APP-PLUS
+ uni.openBluetoothAdapter({
+ success: () => resolve(),
+ fail: (err: any) => {
+ const msg = String(err?.errMsg || '')
+ const code = err?.errCode
+ if (msg.includes('already') || code === 10001) resolve()
+ else reject(new Error(msg || 'openBluetoothAdapter failed'))
+ },
+ })
+ // #endif
+ // #ifndef APP-PLUS
+ resolve()
+ // #endif
+ })
+}
+
+/** 设置页 onUnmounted 可能已 closeBluetoothAdapter,需重新建链后才能写特征值 */
+function bleEnsureDeviceConnected (deviceId: string): Promise {
+ return new Promise((resolve, reject) => {
+ // #ifdef APP-PLUS
+ uni.createBLEConnection({
+ deviceId,
+ timeout: 15000,
+ success: () => resolve(),
+ fail: (err: any) => {
+ const msg = String(err?.errMsg || '')
+ const code = err?.errCode
+ if (
+ code === -1 ||
+ msg.includes('already') ||
+ msg.includes('Connected') ||
+ msg.includes('connected') ||
+ msg.includes('已连接')
+ ) {
+ resolve()
+ return
+ }
+ reject(new Error(msg || 'createBLEConnection failed'))
+ },
+ })
+ // #endif
+ // #ifndef APP-PLUS
+ resolve()
+ // #endif
+ })
+}
+
+/**
+ * 每次打印前会 createBLEConnection,链路 MTU 可能回到默认 23;若仍按 storage 里 512 分包,实机常丢数据但 write 仍 success。
+ */
+function requestBleMtuNegotiation (deviceId: string, preferredMtu: number): Promise {
+ return new Promise((resolve) => {
+ // #ifdef APP-PLUS
+ const targetMtu = Math.max(20, Math.min(512, Math.round(preferredMtu || 20)))
+ if (targetMtu <= 20 || typeof (uni as any).setBLEMTU !== 'function') {
+ resolve(20)
+ return
+ }
+ let settled = false
+ const done = (value: number) => {
+ if (settled) return
+ settled = true
+ clearTimeout(timer)
+ const m = Math.max(20, Math.round(value || 20))
+ try {
+ uni.setStorageSync(STORAGE_BLE_MTU, m)
+ } catch (_) {}
+ resolve(m)
+ }
+ const timer = setTimeout(() => done(20), 3000)
+ ;(uni as any).setBLEMTU({
+ deviceId,
+ mtu: targetMtu,
+ success: (res: any) => done(Number(res?.mtu) || targetMtu),
+ fail: () => done(20),
+ })
+ // #endif
+ // #ifndef APP-PLUS
+ resolve(20)
+ // #endif
+ })
+}
+
function sendViaBle (
data: number[],
onProgress?: (percent: number) => void
@@ -220,71 +323,205 @@ function sendViaBle (
if (!conn) {
return Promise.reject(new Error('Bluetooth printer not connected.'))
}
- const { deviceId, serviceId, characteristicId, mtu } = conn
- const payloadSize = Math.max(20, Math.round((mtu || BLE_MTU_DEFAULT) > 23 ? (mtu || BLE_MTU_DEFAULT) - 3 : (mtu || BLE_MTU_DEFAULT)))
- const chunks: number[][] = []
- for (let i = 0; i < data.length; i += payloadSize) {
- chunks.push(data.slice(i, i + payloadSize))
- }
- const total = chunks.length
- let sent = 0
- let completed = false
- let timeoutId: ReturnType | null = setTimeout(() => {}, 0)
- const writeDelayMs = payloadSize >= 180 ? 0 : (payloadSize > 20 ? 1 : 8)
-
- const resetTimeout = (reject: (reason?: any) => void) => {
- if (timeoutId) clearTimeout(timeoutId)
- timeoutId = setTimeout(() => {
- if (completed) return
- completed = true
- reject(new Error('BLE write timeout'))
- }, 15000)
- }
+ const { deviceId, serviceId, characteristicId, bleWriteUsesNoResponse } = conn
- function sendNext (): Promise {
- if (completed) {
- return Promise.reject(new Error('BLE write timeout'))
+ const runWritesWithPayloadSize = (payloadSize: number): Promise => {
+ const chunks: number[][] = []
+ for (let i = 0; i < data.length; i += payloadSize) {
+ chunks.push(data.slice(i, i + payloadSize))
}
- if (sent >= total) {
- completed = true
- if (timeoutId) clearTimeout(timeoutId)
- if (onProgress) onProgress(100)
- return Promise.resolve()
+ const total = chunks.length
+ let sent = 0
+ let completed = false
+ let timeoutId: ReturnType | null = setTimeout(() => {}, 0)
+ const writeDelayMs =
+ payloadSize >= 180 ? 0 : payloadSize > 20 ? 2 : 10
+
+ /** 与 bleWriteModeRules 白名单一致:该 UUID 对只认 Write Command,不能切到「默认带响应写」 */
+ const blePairForceNoResponse = blePairRequiresWriteNoResponse(serviceId, characteristicId)
+
+ /** 本 job 内若因 property not support 翻过模式,后续包统一用 effectiveUseNoResp */
+ let effectiveUseNoResp = bleWriteUsesNoResponse || blePairForceNoResponse
+ let hasFlippedWriteModeThisJob = false
+ let pendingPersistUseNoResp: boolean | null = null
+
+ if (blePairForceNoResponse) {
+ try {
+ uni.setStorageSync(PrinterStorageKeys.bleWriteNoResponse, '1')
+ } catch (_) {}
}
- const chunk = chunks[sent]
- const buffer = new ArrayBuffer(chunk.length)
- const view = new DataView(buffer)
- for (let j = 0; j < chunk.length; j++) {
- view.setUint8(j, chunk[j] & 0xff)
+
+ const resetTimeout = (reject: (reason?: any) => void) => {
+ if (timeoutId) clearTimeout(timeoutId)
+ timeoutId = setTimeout(() => {
+ if (completed) return
+ completed = true
+ reject(new Error('BLE write timeout'))
+ }, Math.max(60000, total * 500))
}
- return new Promise((resolve, reject) => {
- resetTimeout(reject)
- uni.writeBLECharacteristicValue({
- deviceId,
+
+ function logBleWriteFail (err: any, useNoResp: boolean, bufferLen: number) {
+ const msg = String(err?.errMsg ?? err?.message ?? '')
+ let errSerialized = ''
+ try {
+ errSerialized = JSON.stringify(err)
+ } catch {
+ errSerialized = String(err)
+ }
+ console.error('[sendViaBle] writeBLECharacteristicValue fail — 完整信息供真机调试复制', {
+ errMsg: msg,
+ errCode: err?.errCode,
+ errno: err?.errno,
+ code: err?.code,
+ writeTypeUsed: useNoResp ? 'writeNoResponse' : '(omitted, default write)',
+ effectiveUseNoResp,
+ bleWriteUsesNoResponseSaved: bleWriteUsesNoResponse,
+ bufferLen,
+ sentIndex: sent,
+ totalChunks: total,
serviceId,
characteristicId,
- value: buffer,
- success: () => {
- if (completed) return
- sent++
- if (onProgress) onProgress(Math.round((sent / total) * 100))
- if (writeDelayMs <= 0) {
- sendNext().then(resolve).catch(reject)
- return
+ deviceId,
+ rawErr: err,
+ errSerialized,
+ blePairForcedNoResponse: blePairForceNoResponse,
+ })
+ }
+
+ function writeOneBuffer (buffer: ArrayBuffer): Promise {
+ return new Promise((resolve, reject) => {
+ const tryWrite = (useNoResp: boolean, allowFlip: boolean) => {
+ const opts: UniApp.WriteBLECharacteristicValueOption = {
+ deviceId,
+ serviceId,
+ characteristicId,
+ value: buffer,
}
- setTimeout(() => sendNext().then(resolve).catch(reject), writeDelayMs)
- },
- fail: (err: any) => {
- if (completed) return
- completed = true
- if (timeoutId) clearTimeout(timeoutId)
- reject(new Error(err.errMsg || 'BLE write failed'))
- },
+ /** 仅无响应写显式声明;带响应写不传 writeType,与 uni 历史默认一致,避免部分机型报 property not support */
+ if (useNoResp) {
+ opts.writeType = 'writeNoResponse'
+ }
+ uni.writeBLECharacteristicValue({
+ ...opts,
+ success: () => {
+ if (pendingPersistUseNoResp != null) {
+ try {
+ uni.setStorageSync(
+ PrinterStorageKeys.bleWriteNoResponse,
+ pendingPersistUseNoResp ? '1' : '0'
+ )
+ } catch (_) {}
+ pendingPersistUseNoResp = null
+ }
+ resolve()
+ },
+ fail: (err: any) => {
+ const msg = String(err?.errMsg ?? err?.message ?? '')
+ logBleWriteFail(err, useNoResp, buffer.byteLength)
+ const notSupport =
+ msg.includes('property not support') ||
+ msg.includes('not support') ||
+ String(err?.errCode) === '10007'
+ if (allowFlip && !hasFlippedWriteModeThisJob && notSupport) {
+ const nextUseNoResp = !useNoResp
+ /**
+ * 佳博等 Nordic 串口:GATT 虽声明 write,实测只接受 writeNoResponse。
+ * 若 writeNoResponse 偶发失败后翻到「默认写」,首包可能仍 success,从第二包起必现 10007(打印量变长更易触发)。
+ */
+ if (blePairForceNoResponse && !nextUseNoResp) {
+ console.warn(
+ '[sendViaBle] 白名单串口禁止切到带响应写;请保持 writeNoResponse 或检查连接/MTU'
+ )
+ reject(new Error(msg || 'BLE write failed'))
+ return
+ }
+ hasFlippedWriteModeThisJob = true
+ effectiveUseNoResp = nextUseNoResp
+ pendingPersistUseNoResp = effectiveUseNoResp
+ console.warn('[sendViaBle] property not support → 切换写入方式重试本包', {
+ nextMode: effectiveUseNoResp ? 'writeNoResponse' : 'defaultWrite(no writeType)',
+ })
+ tryWrite(effectiveUseNoResp, false)
+ return
+ }
+ reject(new Error(msg || 'BLE write failed'))
+ },
+ })
+ }
+ tryWrite(effectiveUseNoResp, !hasFlippedWriteModeThisJob)
})
- })
+ }
+
+ function sendNext (): Promise {
+ if (completed) {
+ return Promise.reject(new Error('BLE write timeout'))
+ }
+ if (sent >= total) {
+ completed = true
+ if (timeoutId) clearTimeout(timeoutId)
+ /** 末包 write 成功后立刻 resolve 时,部分机芯尚未吃完缓冲;短延迟再结束,减少「界面成功但不出纸」 */
+ const settleMs = data.length > 400 ? 180 : 50
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ if (onProgress) onProgress(100)
+ resolve()
+ }, settleMs)
+ })
+ }
+ const chunk = chunks[sent]
+ const buffer = new ArrayBuffer(chunk.length)
+ const view = new DataView(buffer)
+ for (let j = 0; j < chunk.length; j++) {
+ view.setUint8(j, chunk[j] & 0xff)
+ }
+ return new Promise((resolve, reject) => {
+ resetTimeout(reject)
+ writeOneBuffer(buffer)
+ .then(() => {
+ if (completed) return
+ sent++
+ if (onProgress) onProgress(Math.round((sent / total) * 100))
+ if (writeDelayMs <= 0) {
+ sendNext().then(resolve).catch(reject)
+ return
+ }
+ setTimeout(() => sendNext().then(resolve).catch(reject), writeDelayMs)
+ })
+ .catch((e: any) => {
+ if (completed) return
+ completed = true
+ if (timeoutId) clearTimeout(timeoutId)
+ reject(e instanceof Error ? e : new Error(String(e?.message || e || 'BLE write failed')))
+ })
+ })
+ }
+
+ return sendNext()
+ }
+
+ /**
+ * 单包 ATT 可写字节数:理论为 mtu-3;MTU≤23 时旧代码误用 mtu 本值(如 23)会超过链路真上限 20,导致截断/无出纸却 write success。
+ * 协商到 512 时不少佳博/安卓组合仍不能稳定传 500+ 字节/包,需再压到安全上限。
+ */
+ const mtuToPayloadSize = (negotiatedMtu: number) => {
+ const mtu = Math.max(23, Math.min(512, Math.round(negotiatedMtu || 23)))
+ const attPayload = Math.max(20, mtu - 3)
+ const SAFE_BLE_WRITE_CAP = 182
+ return Math.min(attPayload, SAFE_BLE_WRITE_CAP)
}
- return sendNext()
+ // #ifdef APP-PLUS
+ if (conn.deviceType === 'ble') {
+ const driver = getPrinterDriverByKey(getCurrentPrinterDriverKey())
+ const preferred = driver.preferredBleMtu || BLE_MTU_DEFAULT
+ return bleOpenAdapter()
+ .then(() => bleEnsureDeviceConnected(deviceId))
+ .then(() => new Promise((r) => setTimeout(r, 100)))
+ .then(() => requestBleMtuNegotiation(deviceId, preferred))
+ .then((negotiated) => runWritesWithPayloadSize(mtuToPayloadSize(negotiated)))
+ }
+ // #endif
+ return runWritesWithPayloadSize(mtuToPayloadSize(conn.mtu || BLE_MTU_DEFAULT))
}
function sendViaClassic (
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/printerReadiness.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/printerReadiness.ts
index 7751361..f05d095 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/printerReadiness.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/printerReadiness.ts
@@ -13,8 +13,9 @@ export function isPrinterReadySync(): boolean {
}
/**
- * 检测系统蓝牙是否开启(APP 端)。H5 返回 false。
- * 用于在「未选打印机」时区分是否需先开蓝牙(仍统一用同一套文案弹窗)。
+ * 检测 uni 蓝牙适配器状态(APP 端)。注意:若本页未调用过 openBluetoothAdapter,
+ * getBluetoothAdapterState 常返回 available: false,易误判;打印流程请以 isPrinterReadySync + 实际 write 结果为准。
+ * H5 返回 false。
*/
export function checkBluetoothAdapterAvailable(): Promise {
return new Promise((resolve) => {
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
new file mode 100644
index 0000000..e8f4cc1
--- /dev/null
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
@@ -0,0 +1,45 @@
+/**
+ * 与 NativeTemplateCommandBuilder.toMillimeter 一致(px 按 96dpi 转 mm),
+ * 用于判断模板是否适合走 native-fast-printer 的 TSC 模板指令(常见标签幅宽约 4 英寸级)。
+ */
+import type { SystemLabelTemplate } from './types/printer'
+
+const DESIGN_DPI = 96
+
+/** 常见 4″ 标签机安全上限(mm),略放宽 */
+const NATIVE_FAST_MAX_WIDTH_MM = 112
+const NATIVE_FAST_MAX_HEIGHT_MM = 320
+
+export function templateSizeToMillimeters (
+ unit: string | undefined,
+ width: number,
+ height: number
+): { widthMm: number; heightMm: number } {
+ const u = String(unit || 'inch').toLowerCase()
+ let widthMm = 0
+ let heightMm = 0
+ if (u === 'mm') {
+ widthMm = width
+ heightMm = height
+ } else if (u === 'cm') {
+ widthMm = width * 10
+ heightMm = height * 10
+ } else if (u === 'px') {
+ widthMm = (width / DESIGN_DPI) * 25.4
+ heightMm = (height / DESIGN_DPI) * 25.4
+ } else {
+ widthMm = width * 25.4
+ heightMm = height * 25.4
+ }
+ return { widthMm, heightMm }
+}
+
+export function isTemplateWithinNativeFastPrintBounds (
+ template: Pick
+): boolean {
+ const w = Number(template.width) || 0
+ const h = Number(template.height) || 0
+ if (w <= 0 || h <= 0) return false
+ const { widthMm, heightMm } = templateSizeToMillimeters(template.unit, w, h)
+ return widthMm <= NATIVE_FAST_MAX_WIDTH_MM && heightMm <= NATIVE_FAST_MAX_HEIGHT_MM
+}
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
index 63642f1..1681b55 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
@@ -279,6 +279,7 @@ export function buildTscImageLabel (
for (let i = 0; i < bitmapBytes.length; i++) out.push(bitmapBytes[i])
out.push(0x0d, 0x0a)
add(`PRINT 1,${printQty}`)
+ add('FEED 1')
return out
}
diff --git a/美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts b/美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
index 0739ff3..3fb8514 100644
--- a/美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
+++ b/美国版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
@@ -30,6 +30,13 @@ export interface PrintImageOptions {
heightMm?: number
x?: number
y?: number
+ /** 将光栅最上方若干行置白,减轻 canvas 缩放/二值化在首行产生的噪点横条 */
+ clearTopRasterRows?: number
+ /**
+ * 解码 PNG 后缩放到目标点阵时是否双线性插值。标签线稿/文字建议 false(最近邻),
+ * 否则二值化后易出现竖向条纹、左右「错列」与预览不一致。
+ */
+ bilinearImageScale?: boolean
}
export interface MonochromeImageData {
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs
index 6c8bb17..9a2aaa0 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs
+++ b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Text.Json;
namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
@@ -40,6 +41,12 @@ public class UsAppLabelPrintInputVo
public Dictionary? PrintInputJson { get; set; }
///
+ /// 可选:App 端合并后的完整模板快照(与平台导出 label-template JSON 同构,含 elements[].config)。
+ /// 传入时明细表 RenderDataJson 优先存此快照,便于打印历史与出纸结果一致;未传时仍由服务端 PreviewAsync 生成。
+ ///
+ public JsonElement? TemplateSnapshot { get; set; }
+
+ ///
/// 打印机Id(可选,若业务需要追踪)
///
public string? PrinterId { get; set; }
diff --git a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs b/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
index 7b0d54d..cf45400 100644
--- a/美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
+++ b/美国版/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
throw new UserFriendlyException("该标签不属于当前门店");
}
- // 解析模板 elements(与预览一致的渲染数据)
- var resolvedTemplate = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo
- {
- LabelCode = labelCode,
- ProductId = input.ProductId?.Trim(),
- BaseTime = input.BaseTime,
- PrintInputJson = input.PrintInputJson
- });
-
var printInputJsonStr = input.PrintInputJson is null
? null
: JsonSerializer.Serialize(input.PrintInputJson);
- var renderDataJsonStr = JsonSerializer.Serialize(resolvedTemplate);
+
+ string renderDataJsonStr;
+ var snapshotOk = false;
+ if (input.TemplateSnapshot.HasValue)
+ {
+ var snapEl = input.TemplateSnapshot.Value;
+ if (snapEl.ValueKind == JsonValueKind.Object
+ && snapEl.TryGetProperty("elements", out var elArr)
+ && elArr.ValueKind == JsonValueKind.Array)
+ {
+ // App 与出纸一致的合并模板(label-template 同构),供打印历史/重打直接使用
+ renderDataJsonStr = snapEl.GetRawText();
+ snapshotOk = true;
+ }
+ }
+
+ if (!snapshotOk)
+ {
+ var resolvedTemplate = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo
+ {
+ LabelCode = labelCode,
+ ProductId = input.ProductId?.Trim(),
+ BaseTime = input.BaseTime,
+ PrintInputJson = input.PrintInputJson
+ });
+ renderDataJsonStr = JsonSerializer.Serialize(resolvedTemplate);
+ }
var now = DateTime.Now;
var currentUserId = CurrentUser?.Id?.ToString();
diff --git a/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx b/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
index 927553e..174fdb7 100644
--- a/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
+++ b/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
@@ -15,6 +15,7 @@ import {
createDefaultElement,
labelElementsToApiPayload,
resolvedLibraryCategoryForPersist,
+ resolvedValueSourceTypeForSave,
valueSourceTypeForLibraryCategory,
} from '../../../types/labelTemplate';
import type { LocationDto } from '../../../types/location';
--
libgit2 0.21.4