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