Commit 43d16ca62e733f63d39e572b5c556cf8ee4dd302

Authored by 杨鑫
1 parent 599d2940

打印日志

Showing 32 changed files with 2477 additions and 609 deletions
标签模块接口对接说明(8).md renamed to 标签模块接口对接说明.md
... ... @@ -835,6 +835,11 @@ curl -X GET "http://localhost:19001/api/app/us-app-labeling/labeling-tree?locati
835 835 - `template`:`LabelTemplatePreviewDto`
836 836 - `width` / `height` / `unit`:模板物理尺寸
837 837 - `elements[]`:元素数组(对齐前端 editor JSON:`id/elementName/type/x/y/width/height/rotation/border/zIndex/orderNum/config`)
  838 +- `templateProductDefaultValues`:`object | null`
  839 + - 来源:`fl_label_template_product_default.DefaultValuesJson`
  840 + - 命中条件:当前预览上下文的 `templateId + productId + labelTypeId`
  841 + - 建议结构:`{ "elementId": "默认值" }`
  842 + - 未命中时返回 `null`(向后兼容)
838 843  
839 844 `elements[].config` 内常用字段(示例):
840 845  
... ... @@ -902,6 +907,7 @@ curl -X POST "http://localhost:19001/api/app/us-app-labeling/preview" \
902 907 | `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) |
903 908 | `productId` | string | 否 | 打印用产品Id;不传则默认取该标签绑定的第一个产品(用于模板解析) |
904 909 | `printQuantity` | number | 否 | 打印份数;`<=0` 按 1 处理 |
  910 +| `clientRequestId` | string | 否 | 客户端幂等请求Id;同一个值重复调用会直接返回首次创建的 `batchId/taskIds`,避免重复写库 用法:前端/客户端每次点击 Print 生成一个稳定的 clientRequestId(比如 uuid) |
905 911 | `baseTime` | string | 否 | 业务基准时间(用于 DATE/TIME 元素计算) |
906 912 | `printInputJson` | object | 否 | 打印输入(用于模板 PRINT_INPUT 元素),key 建议与模板元素 `inputKey` 对齐 |
907 913 | `printerId` | string | 否 | 打印机Id(可选,用于追踪) |
... ... @@ -911,11 +917,11 @@ curl -X POST &quot;http://localhost:19001/api/app/us-app-labeling/preview&quot; \
911 917 #### 数据落库说明
912 918  
913 919 - **任务表**:`fl_label_print_task`
914   - - 插入 1 条任务记录(`locationId / labelCode / productId / labelTypeId / templateCode / printQuantity / baseTime / printer...` 等)。
  920 + - **一份打印 = 一条任务**:当 `printQuantity = N` 时,后端会插入 **N 条任务记录**(同一次点击 Print 共享一个 `BatchId`,并记录 `CopyIndex=1..N`)。
  921 + - 任务表会保存:本次打印的输入、命中的模板默认值、以及整份 resolved 后的模板快照 JSON,便于追溯/重打。
915 922 - **明细表**:`fl_label_print_data`
916   - - 按 `printQuantity` 插入 N 条明细记录(`copyIndex = 1..N`)。
917   - - `printInputJson`:保存本次打印的原始输入(JSON 字符串)。
918   - - `renderDataJson`:保存本次解析后的模板预览结构(`LabelTemplatePreviewDto`,包含 resolved 后的 `elements[].config`),供追溯/重打使用。
  923 + - **按组件写快照**:每个任务会按模板 `elements[]` 逐个插入明细记录(`ElementId/ElementName/RenderValue/RenderConfigJson`)。
  924 + - 适用于按组件维度审计/统计/追溯。
919 925  
920 926 > 模板解析的数据源来自 `fl_label_template` + `fl_label_template_element`,与预览接口一致。
921 927  
... ... @@ -923,8 +929,11 @@ curl -X POST &quot;http://localhost:19001/api/app/us-app-labeling/preview&quot; \
923 929  
924 930 | 字段 | 类型 | 说明 |
925 931 |---|---|---|
926   -| `taskId` | string | 打印任务Id(用于后续查询/重打/统计) |
  932 +| `taskId` | string | 第 1 份打印任务Id(兼容旧逻辑) |
927 933 | `printQuantity` | number | 实际写入的份数 |
  934 +| `batchId` | string | 本次点击 Print 的批次Id |
  935 +| `taskIds` | string[] | 本次生成的所有任务Id(长度=printQuantity) |
  936 +
928 937  
929 938 #### 错误与边界
930 939  
... ... @@ -959,3 +968,122 @@ curl -X POST &quot;http://localhost:19001/api/app/us-app-labeling/print&quot; \
959 968 -d '{"locationId":"11111111-1111-1111-1111-111111111111","labelCode":"LBL_CHICKEN_DEFROST","productId":"22222222-2222-2222-2222-222222222222","printQuantity":2,"baseTime":"2026-03-26T10:30:00","printInputJson":{"price":"12.99"}}'
960 969 ```
961 970  
  971 +---
  972 +
  973 +## 接口 10:App 打印日志(当前登录账号 + 当前门店)
  974 +
  975 +**场景**:移动端“打印记录/历史”页面。只展示**当前登录账号**在**当前门店**打印的记录,便于追溯/重打。
  976 +
  977 +### 10.1 分页获取打印日志
  978 +
  979 +#### HTTP
  980 +
  981 +- **方法**:`POST`(与本模块其它复杂入参接口一致;若与 Swagger 不一致,**以 Swagger 为准**)
  982 +- **路径**:`/api/app/us-app-labeling/get-print-log-list`
  983 +- **鉴权**:需要登录(`Authorization: Bearer ...`)
  984 +
  985 +#### 入参(Body:`PrintLogGetListInputVo`)
  986 +
  987 +> 本项目分页约定:`skipCount` 表示 **页码(从 1 开始)**,不是 0 基 offset。
  988 +
  989 +| 参数名(JSON) | 类型 | 必填 | 说明 |
  990 +|---|---|---|---|
  991 +| `locationId` | string | 是 | 当前门店Id(仅返回该门店记录) |
  992 +| `skipCount` | number | 否 | 页码,从 1 开始;默认 1 |
  993 +| `maxResultCount` | number | 否 | 每页条数;默认按后端/ABP 默认 |
  994 +
  995 +#### 过滤条件(后端固定逻辑)
  996 +
  997 +- `fl_label_print_task.CreatedBy == CurrentUser.Id`
  998 +- `fl_label_print_task.LocationId == locationId`
  999 +- 按时间倒序:`PrintedAt ?? CreationTime`(越新的越靠前)
  1000 +
  1001 +#### 出参(`PagedResultWithPageDto<PrintLogItemDto>`)
  1002 +
  1003 +| 字段 | 类型 | 说明 |
  1004 +|---|---|---|
  1005 +| `pageIndex` | number | 当前页码(从 1 开始) |
  1006 +| `pageSize` | number | 每页条数 |
  1007 +| `totalCount` | number | 总条数 |
  1008 +| `totalPages` | number | 总页数 |
  1009 +| `items` | PrintLogItemDto[] | 列表 |
  1010 +
  1011 +`PrintLogItemDto`:
  1012 +
  1013 +| 字段 | 类型 | 说明 |
  1014 +|---|---|---|
  1015 +| `taskId` | string | 任务Id(fl_label_print_task.Id) |
  1016 +| `batchId` | string | 批次Id(同一次点击 Print 共享) |
  1017 +| `copyIndex` | number | 第几份(从 1 开始) |
  1018 +| `labelId` | string | 标签Id |
  1019 +| `labelCode` | string | 标签编码(来自 fl_label.LabelCode) |
  1020 +| `productId` | string | 产品Id |
  1021 +| `productName` | string | 产品名(来自 fl_product.ProductName;无则 “无”) |
  1022 +| `typeName` | string | 标签类型名称(来自 fl_label_type.TypeName) |
  1023 +| `labelSizeText` | string | 模板尺寸(宽高+单位,如 `2.00x2.00inch` / `6.00x4.00cm`) |
  1024 +| `printDataList` | PrintLogDataItemDto[] | 本次打印内容快照(来自 fl_label_print_data,按 taskId 关联) |
  1025 +| `printedAt` | string | 打印时间(PrintedAt ?? CreationTime) |
  1026 +| `operatorName` | string | 操作人姓名(当前登录账号 Name) |
  1027 +| `locationName` | string | 门店名称 |
  1028 +
  1029 +`PrintLogDataItemDto`:
  1030 +
  1031 +| 字段 | 类型 | 说明 |
  1032 +|---|---|---|
  1033 +| `elementId` | string | 模板组件Id(fl_label_print_data.ElementId) |
  1034 +| `renderValue` | string | 最终渲染值(fl_label_print_data.RenderValue) |
  1035 +| `renderConfigJson` | object | 最终渲染配置(fl_label_print_data.RenderConfigJson 反序列化) |
  1036 +
  1037 +#### curl
  1038 +
  1039 +```bash
  1040 +curl -X POST "http://localhost:19001/api/app/us-app-labeling/get-print-log-list" \
  1041 + -H "Authorization: <data.token>" \
  1042 + -H "Content-Type: application/json" \
  1043 + -d '{"locationId":"11111111-1111-1111-1111-111111111111","skipCount":1,"maxResultCount":20}'
  1044 +```
  1045 +
  1046 +---
  1047 +
  1048 +## 接口 11:App 重新打印(根据任务Id重打)
  1049 +
  1050 +**场景**:移动端“打印记录/历史”页面点击 **Reprint**。后端根据历史任务 `taskId` 创建一批新的打印任务与明细。
  1051 +
  1052 +### 11.1 重打
  1053 +
  1054 +#### HTTP
  1055 +
  1056 +- **方法**:`POST`
  1057 +- **路径**:`/api/app/us-app-labeling/reprint`(若与 Swagger 不一致,**以 Swagger 为准**)
  1058 +- **鉴权**:需要登录(`Authorization: Bearer ...`)
  1059 +
  1060 +#### 入参(Body:`UsAppLabelReprintInputVo`)
  1061 +
  1062 +| 参数名(JSON) | 类型 | 必填 | 说明 |
  1063 +|---|---|---|---|
  1064 +| `locationId` | string | 是 | 当前门店Id(后端校验历史任务必须属于该门店) |
  1065 +| `taskId` | string | 是 | 历史打印任务Id(`fl_label_print_task.Id`) |
  1066 +| `printQuantity` | number | 否 | 重新打印份数;`<=0` 按 1 处理;默认 1 |
  1067 +| `clientRequestId` | string | 否 | 客户端幂等请求Id;同一个值重复调用会直接返回首次创建的 `batchId/taskIds`,避免重复写库 |
  1068 +| `printerId` | string | 否 | 可选,覆盖历史任务的打印机Id |
  1069 +| `printerMac` | string | 否 | 可选,覆盖历史任务的打印机MAC |
  1070 +| `printerAddress` | string | 否 | 可选,覆盖历史任务的打印机地址 |
  1071 +
  1072 +#### 权限校验(后端固定逻辑)
  1073 +
  1074 +- 历史任务必须满足:`CreatedBy == CurrentUser.Id`
  1075 +- 且 `LocationId == locationId`
  1076 +
  1077 +#### 出参(`UsAppLabelPrintOutputDto`)
  1078 +
  1079 +字段与接口 9 一致:`taskId / printQuantity / batchId / taskIds`
  1080 +
  1081 +#### curl
  1082 +
  1083 +```bash
  1084 +curl -X POST "http://localhost:19001/api/app/us-app-labeling/reprint" \
  1085 + -H "Authorization: <data.token>" \
  1086 + -H "Content-Type: application/json" \
  1087 + -d '{"locationId":"11111111-1111-1111-1111-111111111111","taskId":"3a205389-78dd-4750-51ab-720344c9f607","printQuantity":1}'
  1088 +```
  1089 +
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -215,6 +215,7 @@ import {
215 215 postUsAppLabelPrint,
216 216 US_APP_LABEL_PRINT_PATH,
217 217 } from '../../services/usAppLabeling'
  218 +import { savePrintTemplateSnapshotForTask } from '../../utils/printSnapshotStorage'
218 219 import {
219 220 applyTemplateProductDefaultValuesToTemplate,
220 221 extractTemplateProductDefaultValuesFromPreviewPayload,
... ... @@ -547,6 +548,15 @@ const goBluetoothPage = () =&gt; {
547 548 uni.navigateTo({ url: '/pages/labels/bluetooth' })
548 549 }
549 550  
  551 +/** 接口 9 可选 clientRequestId(文档 10 幂等) */
  552 +function createPrintClientRequestId (): string {
  553 + const c = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto : undefined
  554 + if (c && typeof c.randomUUID === 'function') {
  555 + return c.randomUUID()
  556 + }
  557 + return `print-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
  558 +}
  559 +
550 560 const handlePrint = async () => {
551 561 if (isPrinting.value || previewLoading.value || !systemTemplate.value) return
552 562  
... ... @@ -660,17 +670,39 @@ const handlePrint = async () =&gt; {
660 670 let printLogRequestBody: ReturnType<typeof buildUsAppLabelPrintRequestBody> = null
661 671 try {
662 672 const bt = getBluetoothConnection()
  673 + /**
  674 + * 接口 9 落库必须与本次出纸使用的合并模板完全一致(与 labelPrintJobPayload.template 同源),
  675 + * 避免另起 buildPrintPersistTemplateSnapshot(base) 与 computeMergedPreviewTemplate() 细微偏差,
  676 + * 导致库内缺用户输入的价签/过敏原/数字/日期,重打与预览不一致。
  677 + */
  678 + const persistTemplateDoc = JSON.parse(
  679 + JSON.stringify(labelPrintJobPayload.template)
  680 + ) as Record<string, unknown>
  681 +
663 682 printLogRequestBody = buildUsAppLabelPrintRequestBody({
664 683 locationId: getCurrentStoreId(),
665 684 labelCode: labelCode.value,
666 685 productId: productId.value || undefined,
667 686 printQuantity: printQty.value,
668   - printInputJson,
669   - templateSnapshot: labelPrintJobPayload.template,
  687 + mergedTemplate: persistTemplateDoc,
  688 + clientRequestId: createPrintClientRequestId(),
670 689 printerMac: bt?.deviceId || undefined,
671 690 })
672 691 if (printLogRequestBody) {
673   - await postUsAppLabelPrint(printLogRequestBody)
  692 + const printRes = await postUsAppLabelPrint(printLogRequestBody)
  693 + /** 本机快照:列表接口 renderTemplateJson 常为设计器占位,重打需与当次出纸合并模板一致 */
  694 + try {
  695 + const tid = String(
  696 + (printRes as { taskId?: string })?.taskId
  697 + ?? (printRes as { TaskId?: string })?.TaskId
  698 + ?? '',
  699 + ).trim()
  700 + if (tid) {
  701 + savePrintTemplateSnapshotForTask(tid, JSON.stringify(persistTemplateDoc))
  702 + }
  703 + } catch {
  704 + /* 忽略快照写入失败 */
  705 + }
674 706 }
675 707 } catch (syncErr: unknown) {
676 708 if (!isUsAppSessionExpiredError(syncErr)) {
... ...
美国版/Food Labeling Management App UniApp/src/pages/more/print-log.vue
... ... @@ -34,21 +34,32 @@
34 34 </view>
35 35 </view>
36 36  
37   - <scroll-view class="content" scroll-y>
  37 + <scroll-view
  38 + class="content"
  39 + scroll-y
  40 + @scrolltolower="onScrollToLower"
  41 + >
  42 + <view v-if="loading && !items.length" class="state-block">
  43 + <text class="state-text">Loading…</text>
  44 + </view>
  45 + <view v-else-if="!loading && !items.length" class="state-block">
  46 + <text class="state-text">No print records</text>
  47 + </view>
  48 +
38 49 <!-- 卡片视图 -->
39   - <view v-if="viewMode === 'card'" class="log-list">
  50 + <view v-else-if="viewMode === 'card'" class="log-list">
40 51 <view
41   - v-for="row in printLogData"
42   - :key="row.labelId"
  52 + v-for="row in items"
  53 + :key="row.taskId + '-' + row.copyIndex"
43 54 class="log-card"
44 55 >
45 56 <view class="card-header">
46   - <text class="product-name">{{ row.productName }}</text>
47   - <text class="label-id">{{ row.labelId }}</text>
  57 + <text class="product-name">{{ row.productName || '无' }}</text>
  58 + <text class="label-id">{{ shortRef(row) }}</text>
48 59 </view>
49 60 <view class="card-tags">
50   - <text class="tag">{{ row.category }}</text>
51   - <text class="tag">{{ row.template }}</text>
  61 + <text class="tag">{{ tagLabelSize(row) }}</text>
  62 + <text class="tag">{{ tagTypeName(row) }}</text>
52 63 </view>
53 64 <view class="card-details">
54 65 <view class="detail-row">
... ... @@ -57,15 +68,11 @@
57 68 </view>
58 69 <view class="detail-row">
59 70 <AppIcon name="user" size="sm" color="gray" />
60   - <text class="detail-text">{{ row.printedBy }}</text>
  71 + <text class="detail-text">{{ row.operatorName || '无' }}</text>
61 72 </view>
62 73 <view class="detail-row">
63 74 <AppIcon name="mapPin" size="sm" color="gray" />
64   - <text class="detail-text">{{ row.location }}</text>
65   - </view>
66   - <view class="detail-row">
67   - <AppIcon name="calendar" size="sm" color="gray" />
68   - <text class="detail-text">Expires {{ row.expiryDate }}</text>
  75 + <text class="detail-text">{{ row.locationName || '无' }}</text>
69 76 </view>
70 77 </view>
71 78 <view class="card-footer">
... ... @@ -75,6 +82,12 @@
75 82 </view>
76 83 </view>
77 84 </view>
  85 + <view v-if="loadingMore" class="load-more">
  86 + <text class="load-more-text">Loading more…</text>
  87 + </view>
  88 + <view v-else-if="!hasMore && items.length" class="load-more end">
  89 + <text class="load-more-text">End of list</text>
  90 + </view>
78 91 </view>
79 92  
80 93 <!-- 列表视图 -->
... ... @@ -82,20 +95,22 @@
82 95 <view class="log-table">
83 96 <view class="log-table-header">
84 97 <text class="th th-product">Product</text>
85   - <text class="th th-id">Label ID</text>
  98 + <text class="th th-id">Ref</text>
  99 + <text class="th th-size">Label size</text>
  100 + <text class="th th-type">Type</text>
86 101 <text class="th th-printed">Printed At</text>
87   - <text class="th th-expires">Expires</text>
88 102 <text class="th th-action">Action</text>
89 103 </view>
90 104 <view
91   - v-for="row in printLogData"
92   - :key="row.labelId"
  105 + v-for="row in items"
  106 + :key="row.taskId + '-' + row.copyIndex"
93 107 class="log-table-row"
94 108 >
95   - <text class="td td-product">{{ row.productName }}</text>
96   - <text class="td td-id">{{ row.labelId }}</text>
  109 + <text class="td td-product">{{ row.productName || '无' }}</text>
  110 + <text class="td td-id">{{ shortRef(row) }}</text>
  111 + <text class="td td-size">{{ tagLabelSize(row) }}</text>
  112 + <text class="td td-type">{{ tagTypeName(row) }}</text>
97 113 <text class="td td-printed">{{ row.printedAt }}</text>
98   - <text class="td td-expires">{{ row.expiryDate }}</text>
99 114 <view class="td td-action">
100 115 <view class="reprint-btn-sm" @click="handleReprint(row)">
101 116 <AppIcon name="printer" size="sm" color="white" />
... ... @@ -103,6 +118,9 @@
103 118 </view>
104 119 </view>
105 120 </view>
  121 + <view v-if="loadingMore" class="load-more">
  122 + <text class="load-more-text">Loading more…</text>
  123 + </view>
106 124 </view>
107 125 </scroll-view>
108 126  
... ... @@ -112,20 +130,169 @@
112 130  
113 131 <script setup lang="ts">
114 132 import { ref } from 'vue'
  133 +import { onShow } from '@dcloudio/uni-app'
115 134 import AppIcon from '../../components/AppIcon.vue'
116 135 import SideMenu from '../../components/SideMenu.vue'
117 136 import LocationPicker from '../../components/LocationPicker.vue'
118 137 import { getStatusBarHeight } from '../../utils/statusBar'
119   -import { printLogList } from '../../utils/printLog'
  138 +import { getCurrentStoreId } from '../../utils/stores'
  139 +import {
  140 + fetchUsAppPrintLogList,
  141 + postUsAppLabelReprint,
  142 +} from '../../services/usAppLabeling'
  143 +import {
  144 + consumeReprintEmittedTemplateJsonForPersist,
  145 + printFromPrintLogRow,
  146 +} from '../../utils/printFromPrintDataList'
  147 +import { savePrintTemplateSnapshotForTask } from '../../utils/printSnapshotStorage'
  148 +import { getBluetoothConnection, getPrinterType } from '../../utils/print/printerConnection'
  149 +import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest'
  150 +import type { PrintLogItemDto } from '../../types/usAppLabeling'
120 151  
121 152 const statusBarHeight = getStatusBarHeight()
122 153 const isMenuOpen = ref(false)
123 154 const viewMode = ref<'card' | 'list'>('card')
124 155  
125   -const printLogData = ref(printLogList)
  156 +const items = ref<PrintLogItemDto[]>([])
  157 +const loading = ref(false)
  158 +const loadingMore = ref(false)
  159 +const pageIndex = ref(1)
  160 +const hasMore = ref(true)
  161 +const pageSize = 20
  162 +
  163 +function createClientRequestId (): string {
  164 + const c = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto : undefined
  165 + if (c && typeof c.randomUUID === 'function') {
  166 + return c.randomUUID()
  167 + }
  168 + return `reprint-${Date.now()}-${Math.random().toString(36).slice(2, 12)}`
  169 +}
  170 +
  171 +function shortRef (row: PrintLogItemDto): string {
  172 + const b = String(row.batchId || row.taskId || '').trim()
  173 + if (b.length > 14) return `${b.slice(0, 8)}…`
  174 + return b || row.labelCode || '—'
  175 +}
  176 +
  177 +/** 红框左:labelSizeText(兼容 PascalCase) */
  178 +function tagLabelSize (row: PrintLogItemDto): string {
  179 + const raw = row.labelSizeText ?? (row as unknown as { LabelSizeText?: string }).LabelSizeText
  180 + const s = String(raw ?? '').trim()
  181 + return s || '无'
  182 +}
  183 +
  184 +/** 红框右:typeName(兼容 PascalCase) */
  185 +function tagTypeName (row: PrintLogItemDto): string {
  186 + const raw = row.typeName ?? (row as unknown as { TypeName?: string }).TypeName
  187 + const s = String(raw ?? '').trim()
  188 + return s || '无'
  189 +}
  190 +
  191 +async function loadPage (reset: boolean) {
  192 + const locationId = getCurrentStoreId()
  193 + if (!locationId) {
  194 + items.value = []
  195 + return
  196 + }
  197 + if (reset) {
  198 + pageIndex.value = 1
  199 + hasMore.value = true
  200 + loading.value = true
  201 + } else {
  202 + if (!hasMore.value || loadingMore.value) return
  203 + loadingMore.value = true
  204 + }
  205 + try {
  206 + const p = reset ? 1 : pageIndex.value
  207 + const res = await fetchUsAppPrintLogList({
  208 + locationId,
  209 + skipCount: p,
  210 + maxResultCount: pageSize,
  211 + })
  212 + if (reset) {
  213 + items.value = res.items
  214 + } else {
  215 + items.value = [...items.value, ...res.items]
  216 + }
  217 + hasMore.value = items.value.length < res.totalCount
  218 + if (res.items.length > 0) {
  219 + pageIndex.value = p + 1
  220 + }
  221 + } catch (e) {
  222 + if (!isUsAppSessionExpiredError(e)) {
  223 + const msg = e instanceof Error ? e.message : 'Load failed'
  224 + uni.showToast({ title: msg, icon: 'none', duration: 2500 })
  225 + }
  226 + if (reset) items.value = []
  227 + } finally {
  228 + loading.value = false
  229 + loadingMore.value = false
  230 + }
  231 +}
  232 +
  233 +function onScrollToLower () {
  234 + if (!loading.value && hasMore.value) {
  235 + loadPage(false)
  236 + }
  237 +}
  238 +
  239 +onShow(() => {
  240 + loadPage(true)
  241 +})
126 242  
127   -const handleReprint = (row: any) => {
128   - uni.navigateTo({ url: `/pages/more/print-detail?labelId=${encodeURIComponent(row.labelId)}` })
  243 +const handleReprint = async (row: PrintLogItemDto) => {
  244 + const locationId = getCurrentStoreId()
  245 + if (!locationId) {
  246 + uni.showToast({ title: 'Please select a store', icon: 'none' })
  247 + return
  248 + }
  249 + if (!getPrinterType()) {
  250 + uni.showToast({ title: 'Please connect a printer first', icon: 'none' })
  251 + return
  252 + }
  253 + uni.showLoading({ title: 'Printing…', mask: true })
  254 + try {
  255 + /** 优先 `renderTemplateJson` 完整模板(与接口 9 一致);无则回退 printDataList */
  256 + await printFromPrintLogRow(row, {
  257 + printQty: 1,
  258 + onProgress: (pct) => {
  259 + if (pct > 5 && pct < 100) {
  260 + uni.showLoading({ title: `Printing ${pct}%`, mask: true })
  261 + }
  262 + },
  263 + })
  264 + const bt = getBluetoothConnection()
  265 + uni.showLoading({ title: 'Saving…', mask: true })
  266 + /** 出纸成功后再调接口 11 记重打(与文档「重复打印」落库一致) */
  267 + const reprintRes = await postUsAppLabelReprint({
  268 + locationId,
  269 + taskId: row.taskId,
  270 + printQuantity: 1,
  271 + clientRequestId: createClientRequestId(),
  272 + printerMac: bt?.deviceId || undefined,
  273 + })
  274 + try {
  275 + const nextTid = String(
  276 + (reprintRes as { taskId?: string })?.taskId
  277 + ?? (reprintRes as { TaskId?: string })?.TaskId
  278 + ?? '',
  279 + ).trim()
  280 + const emitted = consumeReprintEmittedTemplateJsonForPersist()
  281 + if (nextTid && emitted) {
  282 + savePrintTemplateSnapshotForTask(nextTid, emitted)
  283 + }
  284 + } catch {
  285 + /* 忽略 */
  286 + }
  287 + uni.showToast({ title: 'Done', icon: 'success' })
  288 + } catch (e: unknown) {
  289 + if (!isUsAppSessionExpiredError(e)) {
  290 + const msg = e instanceof Error ? e.message : 'Reprint failed'
  291 + uni.showToast({ title: msg, icon: 'none', duration: 3000 })
  292 + }
  293 + } finally {
  294 + uni.hideLoading()
  295 + }
129 296 }
130 297  
131 298 const goBack = () => {
... ... @@ -182,10 +349,21 @@ const goBack = () =&gt; {
182 349 color: #fff;
183 350 }
184 351  
  352 +.state-block {
  353 + padding: 80rpx 32rpx;
  354 + text-align: center;
  355 +}
  356 +
  357 +.state-text {
  358 + font-size: 28rpx;
  359 + color: #6b7280;
  360 +}
  361 +
185 362 .content {
186 363 flex: 1;
187 364 padding: 24rpx 28rpx 40rpx;
188 365 box-sizing: border-box;
  366 + height: 0;
189 367 }
190 368  
191 369 .log-list {
... ... @@ -339,7 +517,6 @@ const goBack = () =&gt; {
339 517 border: 1rpx solid #e5e7eb;
340 518 }
341 519  
342   -/* 确保横向滚动条可见(H5/浏览器) */
343 520 .log-table-wrap::-webkit-scrollbar {
344 521 height: 6px;
345 522 }
... ... @@ -386,9 +563,10 @@ const goBack = () =&gt; {
386 563 }
387 564  
388 565 .th-product, .td-product { flex: 0 0 auto; min-width: 140rpx; }
389   -.th-id, .td-id { flex: 0 0 auto; min-width: 140rpx; }
  566 +.th-id, .td-id { flex: 0 0 auto; min-width: 160rpx; }
  567 +.th-size, .td-size { flex: 0 0 auto; min-width: 220rpx; max-width: 360rpx; }
  568 +.th-type, .td-type { flex: 0 0 auto; min-width: 120rpx; }
390 569 .th-printed, .td-printed { flex: 0 0 auto; min-width: 200rpx; }
391   -.th-expires, .td-expires { flex: 0 0 auto; min-width: 200rpx; }
392 570 .th-action, .td-action {
393 571 flex: 0 0 80rpx;
394 572 width: 80rpx;
... ... @@ -400,7 +578,9 @@ const goBack = () =&gt; {
400 578  
401 579 .td-product { color: #111827; font-weight: 500; }
402 580 .td-id { color: #6b7280; font-size: 24rpx; }
403   -.td-printed, .td-expires { color: #4b5563; }
  581 +.td-size,
  582 +.td-type { color: #4b5563; font-size: 24rpx; }
  583 +.td-printed { color: #4b5563; }
404 584  
405 585 .reprint-btn-sm {
406 586 display: inline-flex;
... ... @@ -415,4 +595,16 @@ const goBack = () =&gt; {
415 595 .reprint-btn-sm:active {
416 596 opacity: 0.9;
417 597 }
  598 +
  599 +.load-more {
  600 + padding: 24rpx;
  601 + text-align: center;
  602 +}
  603 +.load-more.end .load-more-text {
  604 + color: #9ca3af;
  605 +}
  606 +.load-more-text {
  607 + font-size: 24rpx;
  608 + color: #6b7280;
  609 +}
418 610 </style>
... ...
美国版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
1 1 import type {
  2 + PrintLogGetListInputVo,
  3 + PrintLogItemDto,
2 4 UsAppLabelCategoryTreeNodeDto,
3 5 UsAppLabelingProductNodeDto,
4 6 UsAppLabelPreviewInputVo,
5 7 UsAppLabelPrintInputVo,
6 8 UsAppLabelPrintOutputDto,
  9 + UsAppLabelReprintInputVo,
7 10 UsAppLabelTypeNodeDto,
8 11 UsAppProductCategoryNodeDto,
9 12 } from '../types/usAppLabeling'
  13 +import { extractPagedItems } from '../utils/pagedList'
10 14 import { usAppApiRequest } from '../utils/usAppApiRequest'
11 15  
12 16 /** 接口 9:与文档路径一致,供日志与请求共用 */
... ... @@ -106,6 +110,13 @@ export async function postUsAppLabelPreview(body: UsAppLabelPreviewInputVo): Pro
106 110 * 注意:仅供业务落库场景调用;打印机设置页「测试打印」、蓝牙页 Test Print 等 **不得** 使用(避免脏数据)。
107 111 */
108 112 export async function postUsAppLabelPrint(body: UsAppLabelPrintInputVo): Promise<UsAppLabelPrintOutputDto> {
  113 + console.log('[UsAppLabelPrint] 接口 9 请求前 — path:', US_APP_LABEL_PRINT_PATH)
  114 + console.log('[UsAppLabelPrint] 接口 9 请求体 body:', body)
  115 + try {
  116 + console.log('[UsAppLabelPrint] 接口 9 请求体 JSON:', JSON.stringify(body))
  117 + } catch {
  118 + console.log('[UsAppLabelPrint] 接口 9 请求体 JSON 序列化失败(含循环引用等)')
  119 + }
109 120 return usAppApiRequest<UsAppLabelPrintOutputDto>({
110 121 path: US_APP_LABEL_PRINT_PATH,
111 122 method: 'POST',
... ... @@ -119,9 +130,9 @@ export function buildUsAppLabelPrintRequestBody(input: {
119 130 labelCode?: string | null
120 131 productId?: string | null
121 132 printQuantity: number
122   - printInputJson: Record<string, unknown>
123   - /** 与 buildLabelPrintJobPayload().template 同构,落库 RenderDataJson */
124   - templateSnapshot?: Record<string, unknown> | null
  133 + /** 与 buildLabelPrintJobPayload().template 同构,写入接口 printInputJson 供重打 */
  134 + mergedTemplate: Record<string, unknown>
  135 + clientRequestId?: string | null
125 136 printerMac?: string | null
126 137 printerAddress?: string | null
127 138 }): UsAppLabelPrintInputVo | null {
... ... @@ -129,20 +140,22 @@ export function buildUsAppLabelPrintRequestBody(input: {
129 140 const labelCode = String(input.labelCode || '').trim()
130 141 if (!locationId || !labelCode) return null
131 142  
  143 + let printInputJson: Record<string, unknown>
  144 + try {
  145 + printInputJson = JSON.parse(JSON.stringify(input.mergedTemplate)) as Record<string, unknown>
  146 + } catch {
  147 + return null
  148 + }
  149 +
132 150 const body: UsAppLabelPrintInputVo = {
133 151 locationId,
134 152 labelCode,
135 153 printQuantity: Math.max(1, Math.round(Number(input.printQuantity) || 1)),
136   - printInputJson: { ...input.printInputJson },
  154 + printInputJson,
137 155 baseTime: new Date().toISOString(),
138 156 }
139   - if (input.templateSnapshot && typeof input.templateSnapshot === 'object') {
140   - try {
141   - body.templateSnapshot = JSON.parse(JSON.stringify(input.templateSnapshot)) as Record<string, unknown>
142   - } catch {
143   - /* 忽略快照克隆失败 */
144   - }
145   - }
  157 + const cid = String(input.clientRequestId || '').trim()
  158 + if (cid) body.clientRequestId = cid
146 159 const pid = String(input.productId || '').trim()
147 160 if (pid) body.productId = pid
148 161 const mac = String(input.printerMac || '').trim()
... ... @@ -154,7 +167,7 @@ export function buildUsAppLabelPrintRequestBody(input: {
154 167  
155 168 /**
156 169 * 接口 9:仅在 **标签预览页**(`pages/labels/preview`)用户打印真实标签、出纸成功后落库。
157   - * `printInputJson` 与预览/原生 dataJson 同源;缺少 `locationId` 或 `labelCode` 时不发请求。
  170 + * `mergedTemplate` 写入请求体 `printInputJson`(与 label-template 同构);缺少 `locationId` 或 `labelCode` 时不发请求。
158 171 * 测试打印模板(printers / bluetooth Test Print)不走此函数。
159 172 */
160 173 export async function reportUsAppLabelPrintIfReady(input: {
... ... @@ -162,8 +175,8 @@ export async function reportUsAppLabelPrintIfReady(input: {
162 175 labelCode?: string | null
163 176 productId?: string | null
164 177 printQuantity: number
165   - printInputJson: Record<string, unknown>
166   - templateSnapshot?: Record<string, unknown> | null
  178 + mergedTemplate: Record<string, unknown>
  179 + clientRequestId?: string | null
167 180 printerMac?: string | null
168 181 printerAddress?: string | null
169 182 }): Promise<UsAppLabelPrintOutputDto | null> {
... ... @@ -171,3 +184,28 @@ export async function reportUsAppLabelPrintIfReady(input: {
171 184 if (!body) return null
172 185 return postUsAppLabelPrint(body)
173 186 }
  187 +
  188 +/** 接口 10:分页打印日志 */
  189 +export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) {
  190 + const raw = await usAppApiRequest<unknown>({
  191 + path: '/api/app/us-app-labeling/get-print-log-list',
  192 + method: 'POST',
  193 + auth: true,
  194 + data: {
  195 + locationId: input.locationId,
  196 + skipCount: input.skipCount ?? 1,
  197 + maxResultCount: input.maxResultCount ?? 20,
  198 + },
  199 + })
  200 + return extractPagedItems<PrintLogItemDto>(raw)
  201 +}
  202 +
  203 +/** 接口 11:重打并返回含 mergedTemplateJson 的出参 */
  204 +export async function postUsAppLabelReprint (body: UsAppLabelReprintInputVo): Promise<UsAppLabelPrintOutputDto> {
  205 + return usAppApiRequest<UsAppLabelPrintOutputDto>({
  206 + path: '/api/app/us-app-labeling/reprint',
  207 + method: 'POST',
  208 + auth: true,
  209 + data: body,
  210 + })
  211 +}
... ...
美国版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
... ... @@ -61,14 +61,14 @@ export interface UsAppLabelPrintInputVo {
61 61 labelCode: string
62 62 productId?: string
63 63 printQuantity?: number
  64 + /** 客户端幂等 Id(接口 9 文档 optional) */
  65 + clientRequestId?: string
64 66 baseTime?: string
65   - /** 扁平 PRINT_INPUT 组装(审计/再合并用);与 App 内 buildPrintInputJson 一致 */
66   - printInputJson?: Record<string, unknown>
67 67 /**
68   - * 与平台导出 label-template-*.json 同构的合并后模板(含 elements[].config);
69   - * 服务端优先写入明细 RenderDataJson,便于打印历史按「整模板」重打,与出纸一致。
  68 + * 对齐《标签模块接口对接说明(10)》:存**可再次打印**的合并模板(与 label-template-*.json 同构,含 elements[].config)。
  69 + * 与 `buildLabelPrintJobPayload().template` 一致;服务端写入落库字段并用于重打。
70 70 */
71   - templateSnapshot?: Record<string, unknown>
  71 + printInputJson?: Record<string, unknown>
72 72 printerId?: string
73 73 printerMac?: string
74 74 printerAddress?: string
... ... @@ -77,4 +77,60 @@ export interface UsAppLabelPrintInputVo {
77 77 export interface UsAppLabelPrintOutputDto {
78 78 taskId: string
79 79 printQuantity: number
  80 + batchId?: string
  81 + taskIds?: string[]
  82 + /** 接口 11:服务端返回的历史合并模板 JSON,供本地 BLE 打印 */
  83 + mergedTemplateJson?: string | null
  84 +}
  85 +
  86 +/** 接口 10 分页入参 */
  87 +export interface PrintLogGetListInputVo {
  88 + locationId: string
  89 + skipCount?: number
  90 + maxResultCount?: number
  91 +}
  92 +
  93 +/** 接口 10 打印明细项(元素快照) */
  94 +export interface PrintLogDataItemDto {
  95 + elementId?: string
  96 + renderValue?: string
  97 + /** 完整元素 JSON(与模板 elements[] 单项同构) */
  98 + renderConfigJson?: unknown
  99 +}
  100 +
  101 +/** 接口 10 列表项 */
  102 +export interface PrintLogItemDto {
  103 + taskId: string
  104 + batchId: string
  105 + copyIndex: number
  106 + labelId: string
  107 + labelCode: string
  108 + productId?: string | null
  109 + productName: string
  110 + printedAt: string
  111 + operatorName: string
  112 + locationName: string
  113 + labelCategoryName?: string | null
  114 + labelTemplateSummary?: string | null
  115 + /** 与接口/预览 labelSizeText 一致,如 2.00x2.00inch */
  116 + labelSizeText?: string | null
  117 + /** 标签种类名 fl_label_type.TypeName */
  118 + typeName?: string | null
  119 + /** 用于重打:元素列表(接口返回 printDataList) */
  120 + printDataList?: PrintLogDataItemDto[] | null
  121 + /**
  122 + * 列表接口若返回与接口 9 同构的完整模板 JSON 字符串(或含 `printInputJson` 的保存体),重打应优先用此字段以保留坐标与样式相关 config。
  123 + */
  124 + renderTemplateJson?: string | null
  125 +}
  126 +
  127 +/** 接口 11 重打入参 */
  128 +export interface UsAppLabelReprintInputVo {
  129 + locationId: string
  130 + taskId: string
  131 + printQuantity?: number
  132 + clientRequestId?: string
  133 + printerId?: string
  134 + printerMac?: string
  135 + printerAddress?: string
80 136 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/buildLabelPrintPayload.ts
... ... @@ -40,6 +40,11 @@ function cloneJsonSafeConfig(cfg: Record&lt;string, unknown&gt;): Record&lt;string, unkno
40 40 }
41 41 }
42 42  
  43 +/** 落库 config:完整深拷贝,避免白名单遗漏字段导致重打缺价签/过敏原/数字等 */
  44 +function mergePersistConfigSnapshot(src: Record<string, unknown>): Record<string, unknown> {
  45 + return cloneJsonSafeConfig(src)
  46 +}
  47 +
43 48 /** 单元素序列化:与 JSON 模板 elements[] 项字段对齐,并保留 PRINT_INPUT 相关根字段 */
44 49 export function serializeElementForLabelTemplateJson(el: SystemTemplateElementBase): Record<string, unknown> {
45 50 const cfg = (el.config || {}) as Record<string, unknown>
... ... @@ -52,11 +57,14 @@ export function serializeElementForLabelTemplateJson(el: SystemTemplateElementBa
52 57 height: el.height,
53 58 rotation: el.rotation ?? 'horizontal',
54 59 border: el.border ?? 'none',
55   - config: cloneJsonSafeConfig(cfg),
  60 + config: mergePersistConfigSnapshot(cfg),
56 61 }
57 62 if (el.valueSourceType) o.valueSourceType = el.valueSourceType
58 63 if (el.inputKey != null && String(el.inputKey).trim()) o.inputKey = el.inputKey
59 64 if (el.elementName != null && String(el.elementName).trim()) o.elementName = el.elementName
  65 + const zx = el as SystemTemplateElementBase & { zIndex?: number; orderNum?: number }
  66 + if (zx.zIndex !== undefined) o.zIndex = zx.zIndex
  67 + if (zx.orderNum !== undefined) o.orderNum = zx.orderNum
60 68 return o
61 69 }
62 70  
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/printInputOptions.ts
... ... @@ -195,6 +195,22 @@ export function buildPrintInputJson(
195 195 }
196 196 }
197 197  
  198 +/**
  199 + * 供接口 9 落库 `printInputJson`:在**纯 JSON 克隆**上再合并多选/自由输入,避免 Vue Proxy 嵌套 config
  200 + * 在部分环境下 `JSON.stringify` 丢字段,导致服务端存下的 RenderDataJson 仍是设计器默认值(无日期、无多选)。
  201 + */
  202 +export function buildPrintPersistTemplateSnapshot(
  203 + base: SystemLabelTemplate,
  204 + optionSelections: Record<string, string[]>,
  205 + freeFieldValues: Record<string, string>,
  206 + dictNames: Record<string, string>
  207 +): SystemLabelTemplate {
  208 + const cloned = JSON.parse(JSON.stringify(base)) as SystemLabelTemplate
  209 + let m = mergePrintOptionSelections(cloned, optionSelections, dictNames)
  210 + m = mergePrintInputFreeFields(m, freeFieldValues)
  211 + return m
  212 +}
  213 +
198 214 /** 本地打印机 / dataJson:与 printInputJson 同键,供模板 {{key}} 或 native 层合并 */
199 215 export function printInputJsonToLabelTemplateData(pj: Record<string, unknown>): LabelTemplateData {
200 216 const out: LabelTemplateData = {}
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
1 1 import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer'
2   -import { resolveMediaUrlForApp } from '../resolveMediaUrl'
  2 +import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl'
3 3 import { sortElementsForPreview } from './normalizePreviewTemplate'
4 4  
5 5 /** 与 Web LabelCanvas.unitToPx 一致:cm 用 37.8px/inch,保证与后台模板坐标系一致 */
... ... @@ -225,20 +225,49 @@ function runLabelPreviewCanvasDraw(
225 225 }
226 226  
227 227 if (type === 'QRCODE' || type === 'BARCODE') {
228   - ctx.setFillStyle('#f3f4f6')
229   - ctx.fillRect(x, y, w || 60, h || 60)
230   - ctx.setStrokeStyle('#9ca3af')
231   - ctx.setLineWidth(1)
232   - ctx.strokeRect(x, y, w || 60, h || 60)
233 228 const d = previewTextForElement(el)
234   - ctx.setFillStyle('#374151')
235   - ctx.setFontSize(10)
236   - const label = type === 'QRCODE' ? 'QR' : 'BC'
237   - ctx.fillText(label, x + 4, y + 14)
238   - if (d) {
239   - const short = d.length > 12 ? `${d.slice(0, 10)}…` : d
240   - ctx.fillText(short, x + 4, y + 28)
  229 + const drawQrBarcodePlaceholder = () => {
  230 + ctx.setFillStyle('#f3f4f6')
  231 + ctx.fillRect(x, y, w || 60, h || 60)
  232 + ctx.setStrokeStyle('#9ca3af')
  233 + ctx.setLineWidth(1)
  234 + ctx.strokeRect(x, y, w || 60, h || 60)
  235 + ctx.setFillStyle('#374151')
  236 + ctx.setFontSize(10)
  237 + const label = type === 'QRCODE' ? 'QR' : 'BC'
  238 + ctx.fillText(label, x + 4, y + 14)
  239 + if (d) {
  240 + const short = d.length > 12 ? `${d.slice(0, 10)}…` : d
  241 + ctx.fillText(short, x + 4, y + 28)
  242 + }
241 243 }
  244 +
  245 + // 管理端可把二维码默认值存为上传图片路径,须按位图绘制而非占位符文本
  246 + if (type === 'QRCODE' && d && storedValueLooksLikeImagePath(d)) {
  247 + const src = resolveMediaUrlForApp(d)
  248 + if (src) {
  249 + uni.getImageInfo({
  250 + src,
  251 + success: (info) => {
  252 + try {
  253 + const dw = w || info.width
  254 + const dh = h || info.height
  255 + ctx.drawImage(info.path, x, y, dw, dh)
  256 + } catch (_) {
  257 + drawQrBarcodePlaceholder()
  258 + }
  259 + next()
  260 + },
  261 + fail: () => {
  262 + drawQrBarcodePlaceholder()
  263 + next()
  264 + },
  265 + })
  266 + return
  267 + }
  268 + }
  269 +
  270 + drawQrBarcodePlaceholder()
242 271 next()
243 272 return
244 273 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts
1 1 /**
2 2 * 部分标签机 BLE 串口:GATT 同时声明 write / writeNoResponse,但数据口实际只接受 Write Command(无响应)。
3 3 * 用默认「带响应写」时常见首包过、第二包起 writeBLECharacteristicValue:fail property not support (10007)。
4   - * printerConnection 对白名单 UUID 会强制全程 writeNoResponse,且禁止在 10007 时翻成「带响应写」(否则长任务/多份打印必挂)。
  4 + * printerManager 在「同时声明两种属性」时对本白名单 UUID 优先选 writeNoResponse;若系统只暴露 write,则绝不强行 Command 写(否则 10007)。
  5 + * sendViaBle 在已选无响应写仍 10007 时,对白名单禁止翻到带响应写(避免佳博长任务第二包必挂)。
5 6 */
6 7  
7 8 export function normalizeBleUuid (uuid: string): string {
... ... @@ -25,3 +26,9 @@ export function blePairRequiresWriteNoResponse (
25 26 const c = normalizeBleUuid(characteristicId)
26 27 return FORCE_WRITE_NO_RESPONSE.some((p) => p.service === s && p.characteristic === c)
27 28 }
  29 +
  30 +/** 是否为本仓库维护的 Nordic UART 风格串口服务(需写前开 notify、分包间留间隔等) */
  31 +export function isNordicUartStyleBleService (serviceId: string): boolean {
  32 + const s = normalizeBleUuid(serviceId)
  33 + return FORCE_WRITE_NO_RESPONSE.some((p) => p.service === s)
  34 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
1 1 import { blePairRequiresWriteNoResponse } from '../bleWriteModeRules'
2 2 import {
3 3 clearPrinter,
  4 + ensureBleUartNotifyIfNeeded,
4 5 getBluetoothConnection,
5 6 getCurrentPrinterDriverKey,
6 7 getPrinterType,
... ... @@ -112,8 +113,35 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive
112 113  
113 114 /**
114 115 * 优先带响应 write(与 uni 默认写入方式一致);仅当没有 write 再用 writeNoResponse(需在下发时传 writeType)。
115   - * 若反选 writeNoResponse 优先,易出现 writeBLECharacteristicValue:fail property not support
  116 + * 若系统 GATT 只声明 write、未声明 writeNoResponse,却强行 writeNoResponse,会报 property not support (10007)
116 117 */
  118 +function hasBleWriteProperty (item: any): boolean {
  119 + const w = item.properties?.write
  120 + return w === true || w === 'true'
  121 +}
  122 +
  123 +function hasBleWriteNoResponseProperty (item: any): boolean {
  124 + const p = item.properties || {}
  125 + return (
  126 + p.writeNoResponse === true ||
  127 + p.writeNoResponse === 'true' ||
  128 + p.writeWithoutResponse === true ||
  129 + p.writeWithoutResponse === 'true'
  130 + )
  131 +}
  132 +
  133 +/**
  134 + * 无 writeNoResponse 属性则绝不走 Command 写。
  135 + * Nordic 白名单在「同时声明 write + writeNoResponse」时优先无响应写(佳博等实测);否则有 write 时优先带响应。
  136 + */
  137 +function pickBleWriteUsesNoResponse (serviceId: string, item: any): boolean {
  138 + const hw = hasBleWriteProperty(item)
  139 + const hn = hasBleWriteNoResponseProperty(item)
  140 + if (!hn) return false
  141 + if (!hw) return true
  142 + return blePairRequiresWriteNoResponse(serviceId, String(item.uuid || ''))
  143 +}
  144 +
117 145 function findBleWriteCharacteristic (deviceId: string): Promise<{
118 146 serviceId: string
119 147 characteristicId: string
... ... @@ -135,41 +163,26 @@ function findBleWriteCharacteristic (deviceId: string): Promise&lt;{
135 163 serviceId,
136 164 success: (charRes) => {
137 165 const chars = charRes.characteristics || []
138   - const hasWrite = (item: any) => {
139   - const w = item.properties?.write
140   - return w === true || w === 'true'
141   - }
142   - const hasWriteNoResp = (item: any) => {
143   - const p = item.properties || {}
144   - return (
145   - p.writeNoResponse === true ||
146   - p.writeNoResponse === 'true' ||
147   - p.writeWithoutResponse === true ||
148   - p.writeWithoutResponse === 'true'
149   - )
150   - }
151   - const writable = (item: any) => hasWrite(item) || hasWriteNoResp(item)
  166 + const writable = (item: any) => hasBleWriteProperty(item) || hasBleWriteNoResponseProperty(item)
152 167 for (const item of chars) {
153 168 const cid = String(item.uuid || '')
154 169 if (blePairRequiresWriteNoResponse(serviceId, cid) && writable(item)) {
155 170 resolve({
156 171 serviceId,
157 172 characteristicId: cid,
158   - bleWriteUsesNoResponse: true,
  173 + bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, item),
159 174 })
160 175 return
161 176 }
162 177 }
163   - const withResp = chars.find(hasWrite)
164   - const noResp = chars.find(hasWriteNoResp)
  178 + const withResp = chars.find(hasBleWriteProperty)
  179 + const noResp = chars.find(hasBleWriteNoResponseProperty)
165 180 const target = withResp || noResp
166 181 if (target) {
167   - const cid = String(target.uuid || '')
168   - const forceNoResp = blePairRequiresWriteNoResponse(serviceId, cid)
169 182 resolve({
170 183 serviceId,
171   - characteristicId: cid,
172   - bleWriteUsesNoResponse: forceNoResp || (!withResp && !!noResp),
  184 + characteristicId: String(target.uuid || ''),
  185 + bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, target),
173 186 })
174 187 return
175 188 }
... ... @@ -215,6 +228,7 @@ function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Pr
215 228 if (!write) {
216 229 throw new Error('No writable characteristic found. This device may not support printing.')
217 230 }
  231 + await ensureBleUartNotifyIfNeeded(device.deviceId, write.serviceId, write.characteristicId)
218 232 const negotiatedMtu = await requestBleMtu(device.deviceId, driver.preferredBleMtu || 20)
219 233 setBluetoothConnection({
220 234 deviceId: device.deviceId,
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
... ... @@ -4,7 +4,11 @@
4 4 import type { ActiveBtDeviceType, PrinterType } from './types/printer'
5 5 import classicBluetooth from './bluetoothTool.js'
6 6 import { getDeviceFingerprint } from '../deviceInfo'
7   -import { blePairRequiresWriteNoResponse } from './bleWriteModeRules'
  7 +import {
  8 + blePairRequiresWriteNoResponse,
  9 + isNordicUartStyleBleService,
  10 + normalizeBleUuid,
  11 +} from './bleWriteModeRules'
8 12 import { getPrinterDriverByKey } from './manager/driverRegistry'
9 13  
10 14 const STORAGE_PRINTER_TYPE = 'printerType'
... ... @@ -282,6 +286,80 @@ function bleEnsureDeviceConnected (deviceId: string): Promise&lt;void&gt; {
282 286 /**
283 287 * 每次打印前会 createBLEConnection,链路 MTU 可能回到默认 23;若仍按 storage 里 512 分包,实机常丢数据但 write 仍 success。
284 288 */
  289 +let bleNordicUartValueListenerAttached = false
  290 +
  291 +function attachBleNordicUartValueListener (): void {
  292 + if (bleNordicUartValueListenerAttached) return
  293 + bleNordicUartValueListenerAttached = true
  294 + try {
  295 + if (typeof uni.onBLECharacteristicValueChange === 'function') {
  296 + uni.onBLECharacteristicValueChange(() => {})
  297 + }
  298 + } catch (_) {}
  299 +}
  300 +
  301 +/**
  302 + * Nordic UART 类标签机:未对 TX 打开 notify 时,部分安卓栈第二包起对 RX 写会统一报 property not support (10007)。
  303 + * 连接后、大批量 write 前各调用一次(幂等)。
  304 + */
  305 +export function ensureBleUartNotifyIfNeeded (
  306 + deviceId: string,
  307 + serviceId: string,
  308 + rxCharacteristicId?: string
  309 +): Promise<void> {
  310 + // #ifndef APP-PLUS
  311 + return Promise.resolve()
  312 + // #endif
  313 + // #ifdef APP-PLUS
  314 + if (!isNordicUartStyleBleService(serviceId)) {
  315 + return Promise.resolve()
  316 + }
  317 + const rx = normalizeBleUuid(rxCharacteristicId || '')
  318 + return new Promise((resolve) => {
  319 + uni.getBLEDeviceCharacteristics({
  320 + deviceId,
  321 + serviceId,
  322 + success: (res) => {
  323 + const chars = ((res as any).characteristics || []) as Array<{ uuid?: string; properties?: Record<string, unknown> }>
  324 + const notifyChar = chars.find((c) => {
  325 + const p = c.properties || {}
  326 + const n =
  327 + p.notify === true ||
  328 + p.notify === 'true' ||
  329 + p.indicate === true ||
  330 + p.indicate === 'true'
  331 + if (!n) return false
  332 + const cid = normalizeBleUuid(String(c.uuid || ''))
  333 + if (rx && cid === rx) return false
  334 + return true
  335 + })
  336 + if (!notifyChar?.uuid) {
  337 + console.warn('[BLE] Nordic 串口:未找到可订阅的 notify/indicate 特征,跳过后续写入可能仍 10007')
  338 + resolve()
  339 + return
  340 + }
  341 + const cid = String(notifyChar.uuid)
  342 + uni.notifyBLECharacteristicValueChange({
  343 + deviceId,
  344 + serviceId,
  345 + characteristicId: cid,
  346 + state: true,
  347 + success: () => {
  348 + attachBleNordicUartValueListener()
  349 + console.log('[BLE] Nordic 串口已订阅 notify:', cid)
  350 + setTimeout(() => resolve(), 80)
  351 + },
  352 + fail: () => {
  353 + resolve()
  354 + },
  355 + })
  356 + },
  357 + fail: () => resolve(),
  358 + })
  359 + })
  360 + // #endif
  361 +}
  362 +
285 363 function requestBleMtuNegotiation (deviceId: string, preferredMtu: number): Promise<number> {
286 364 return new Promise((resolve) => {
287 365 // #ifdef APP-PLUS
... ... @@ -334,23 +412,25 @@ function sendViaBle (
334 412 let sent = 0
335 413 let completed = false
336 414 let timeoutId: ReturnType<typeof setTimeout> | null = setTimeout(() => {}, 0)
  415 + /** Nordic 大包连续 0ms 间隔时,部分机型第二包起 10007;与 enable notify 配合 */
  416 + const nordicUart = isNordicUartStyleBleService(serviceId)
337 417 const writeDelayMs =
338   - payloadSize >= 180 ? 0 : payloadSize > 20 ? 2 : 10
339   -
340   - /** 与 bleWriteModeRules 白名单一致:该 UUID 对只认 Write Command,不能切到「默认带响应写」 */
  418 + nordicUart && payloadSize >= 64
  419 + ? 18
  420 + : payloadSize >= 180
  421 + ? 0
  422 + : payloadSize > 20
  423 + ? 2
  424 + : 10
  425 +
  426 + /** Nordic 白名单:仅用于失败时是否禁止翻到带响应写(佳博等);写入方式以连接时写入 storage 的 bleWriteUsesNoResponse 为准 */
341 427 const blePairForceNoResponse = blePairRequiresWriteNoResponse(serviceId, characteristicId)
342 428  
343 429 /** 本 job 内若因 property not support 翻过模式,后续包统一用 effectiveUseNoResp */
344   - let effectiveUseNoResp = bleWriteUsesNoResponse || blePairForceNoResponse
  430 + let effectiveUseNoResp = bleWriteUsesNoResponse
345 431 let hasFlippedWriteModeThisJob = false
346 432 let pendingPersistUseNoResp: boolean | null = null
347 433  
348   - if (blePairForceNoResponse) {
349   - try {
350   - uni.setStorageSync(PrinterStorageKeys.bleWriteNoResponse, '1')
351   - } catch (_) {}
352   - }
353   -
354 434 const resetTimeout = (reject: (reason?: any) => void) => {
355 435 if (timeoutId) clearTimeout(timeoutId)
356 436 timeoutId = setTimeout(() => {
... ... @@ -517,6 +597,7 @@ function sendViaBle (
517 597 return bleOpenAdapter()
518 598 .then(() => bleEnsureDeviceConnected(deviceId))
519 599 .then(() => new Promise<void>((r) => setTimeout(r, 100)))
  600 + .then(() => ensureBleUartNotifyIfNeeded(deviceId, serviceId, characteristicId))
520 601 .then(() => requestBleMtuNegotiation(deviceId, preferred))
521 602 .then((negotiated) => runWritesWithPayloadSize(mtuToPayloadSize(negotiated)))
522 603 }
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
  1 +import { storedValueLooksLikeImagePath } from '../resolveMediaUrl'
1 2 import {
2 3 createImageBitmapPatch,
3 4 createTextBitmapPatch,
... ... @@ -154,8 +155,9 @@ function resolvePlainTextLikeElement (
154 155 if (v && u && !v.endsWith(u)) return `${v}${u}`
155 156 return v || u
156 157 }
  158 + /** 与预览一致:展示用文案在 config.text;无 text 时不应把 format 模板(如 YYYY-MM-DD)当内容打印 */
157 159 if (type === 'DATE' || type === 'TIME' || type === 'DURATION') {
158   - return getConfigString(config, ['format', 'Format'])
  160 + return ''
159 161 }
160 162 return ''
161 163 }
... ... @@ -167,12 +169,31 @@ function resolveElementText (
167 169 const config = element.config || {}
168 170 const type = String(element.type || '').toUpperCase()
169 171 const hasText = config.text != null && config.text !== ''
  172 + const vst = String(element.valueSourceType || '').toUpperCase()
  173 +
170 174 if (type === 'TEXT_PRICE') {
171 175 const bindingKey = resolveBindingKey(element)
172 176 const boundValue = resolveTemplateFieldValue(data, bindingKey)
  177 + const rawCfg = getConfigString(config, ['text', 'Text'])
  178 + /** FIXED:重打快照里价格已在 config.text,勿用空 data 绑定出 0 */
  179 + if (vst === 'FIXED' && rawCfg.trim()) {
  180 + return formatPriceValue(rawCfg, config)
  181 + }
173 182 const baseValue = boundValue || (hasText ? applyTemplateData(String(config.text), data) : '')
174 183 return baseValue ? formatPriceValue(baseValue, config) : ''
175 184 }
  185 +
  186 + /** FIXED:TEXT_PRODUCT 等勿在 data 为空时仍走 productName 绑定(与快照 config.text 冲突) */
  187 + if (
  188 + vst === 'FIXED' &&
  189 + hasText &&
  190 + (type === 'TEXT_PRODUCT' ||
  191 + type === 'TEXT_CATEGORY' ||
  192 + type === 'TEXT_LABEL_ID')
  193 + ) {
  194 + return applyTemplateData(String(config.text), data)
  195 + }
  196 +
176 197 if (hasText && type === 'TEXT_STATIC') {
177 198 return applyTemplateData(String(config.text), data)
178 199 }
... ... @@ -293,6 +314,14 @@ function resolveTextX (params: {
293 314 return Math.max(0, left + Math.max(0, boxWidth - textWidth))
294 315 }
295 316  
  317 +/** TSC 内置西文字体无法显示全角¥时易成「?」;位图失败走此回退时替换为可打字符(与预览位图路径一致时可显示原符号) */
  318 +function sanitizeTextForTscBuiltinFont (text: string): string {
  319 + return String(text || '')
  320 + .replace(/\uFFE5/g, 'Y')
  321 + .replace(/\u00A5/g, 'Y')
  322 + .replace(/¥/g, 'Y')
  323 +}
  324 +
296 325 function buildTscTemplate (
297 326 template: SystemLabelTemplate,
298 327 data: LabelTemplateData,
... ... @@ -340,6 +369,8 @@ function buildTscTemplate (
340 369 }
341 370 }
342 371  
  372 + const textForTsc = sanitizeTextForTscBuiltinFont(text)
  373 +
343 374 items.push({
344 375 type: 'text',
345 376 x: resolveTextX({
... ... @@ -347,11 +378,11 @@ function buildTscTemplate (
347 378 xPx: element.x,
348 379 widthPx: element.width,
349 380 dpi,
350   - text,
  381 + text: textForTsc,
351 382 scale,
352 383 }),
353 384 y: pxToDots(element.y, dpi),
354   - text,
  385 + text: textForTsc,
355 386 font: 'TSS24.BF2',
356 387 rotation: resolveRotation(element.rotation),
357 388 xScale: scale,
... ... @@ -363,6 +394,17 @@ function buildTscTemplate (
363 394 if (type === 'QRCODE') {
364 395 const value = resolveElementDataValue(element, data)
365 396 if (!value) return
  397 + if (storedValueLooksLikeImagePath(value)) {
  398 + const bitmapPatch = createImageBitmapPatch({
  399 + element: {
  400 + ...element,
  401 + config: { ...config, src: value, url: value, Src: value, Url: value },
  402 + },
  403 + dpi,
  404 + })
  405 + if (bitmapPatch) items.push(bitmapPatch)
  406 + return
  407 + }
366 408 const level = normalizeQrLevel(getConfigString(config, ['errorLevel'], 'M'))
367 409 items.push({
368 410 type: 'qrcode',
... ... @@ -454,7 +496,7 @@ function buildEscTemplate (
454 496 const scale = fontSize >= 28 ? 2 : 1
455 497 items.push({
456 498 type: 'text',
457   - text,
  499 + text: sanitizeTextForTscBuiltinFont(text),
458 500 align,
459 501 bold: String(config.fontWeight || '').toLowerCase() === 'bold',
460 502 widthScale: scale,
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
... ... @@ -8,6 +8,7 @@ import type { MonochromeImageData, PrintImageOptions, StructuredTscTemplate } fr
8 8 function normalizePrinterText (str: string): string {
9 9 return String(str || '')
10 10 .normalize('NFKC')
  11 + .replace(/\uFFE5/g, '\u00A5')
11 12 .replace(/[\u2018\u2019]/g, '\'')
12 13 .replace(/[\u201C\u201D]/g, '"')
13 14 .replace(/[\u2013\u2014]/g, '-')
... ...
美国版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts 0 → 100644
  1 +import { serializeElementForLabelTemplateJson } from './labelPreview/buildLabelPrintPayload'
  2 +import {
  3 + normalizeLabelTemplateFromPreviewApi,
  4 + parseLabelSizeText,
  5 + sortElementsForPreview,
  6 +} from './labelPreview/normalizePreviewTemplate'
  7 +import { printSystemTemplateForCurrentPrinter } from './print/manager/printerManager'
  8 +import type {
  9 + LabelTemplateData,
  10 + SystemLabelTemplate,
  11 + SystemTemplateElementBase,
  12 +} from './print/types/printer'
  13 +import { getPrintTemplateSnapshotForTask } from './printSnapshotStorage'
  14 +import type { PrintLogDataItemDto, PrintLogItemDto } from '../types/usAppLabeling'
  15 +
  16 +/** 本次重打实际送机的模板(焙平后),供接口 11 返回新 taskId 时写入本机快照,支持连续重打 */
  17 +let reprintEmittedTemplateJsonForPersist: string | null = null
  18 +
  19 +export function consumeReprintEmittedTemplateJsonForPersist (): string | null {
  20 + const s = reprintEmittedTemplateJsonForPersist
  21 + reprintEmittedTemplateJsonForPersist = null
  22 + return s
  23 +}
  24 +
  25 +function rememberReprintEmittedTemplate (tmpl: SystemLabelTemplate) {
  26 + try {
  27 + reprintEmittedTemplateJsonForPersist = persistableTemplateJsonFromSystem(tmpl)
  28 + } catch {
  29 + reprintEmittedTemplateJsonForPersist = null
  30 + }
  31 +}
  32 +
  33 +/** 与 preview 落库、接口 printInputJson 根结构一致,便于本机存储与解析 */
  34 +function persistableTemplateJsonFromSystem (tmpl: SystemLabelTemplate): string {
  35 + const doc: Record<string, unknown> = {
  36 + id: String(tmpl.id ?? ''),
  37 + name: String(tmpl.name ?? 'Label'),
  38 + labelType: tmpl.labelType ?? '',
  39 + unit: String(tmpl.unit ?? 'inch'),
  40 + width: Number(tmpl.width) || 0,
  41 + height: Number(tmpl.height) || 0,
  42 + appliedLocation: tmpl.appliedLocation ?? 'ALL',
  43 + showRuler: tmpl.showRuler !== false,
  44 + showGrid: tmpl.showGrid !== false,
  45 + elements: (tmpl.elements || []).map((el) =>
  46 + serializeElementForLabelTemplateJson(el as SystemTemplateElementBase),
  47 + ),
  48 + }
  49 + return JSON.stringify(doc)
  50 +}
  51 +
  52 +function nonEmptyDisplay (value: string | null | undefined): string {
  53 + if (value == null || value === '' || value === '无') return ''
  54 + return String(value).trim()
  55 +}
  56 +
  57 +/**
  58 + * 接口 9/11 落库的快照里 elements[].config 已是最终展示值;重打时 data 不能再带 productName,
  59 + * 否则 TEXT_PRODUCT 会优先解析为 productName(与预览里用 config.text 的「freeze」等冲突,出现两行 sandwich)。
  60 + */
  61 +function labelTemplateDataForSnapshotReprint (): LabelTemplateData {
  62 + return {}
  63 +}
  64 +
  65 +function formatPriceLineForBake (config: Record<string, any>, rawText: string): string {
  66 + const prefix = String(config.prefix ?? config.Prefix ?? '')
  67 + const suffix = String(config.suffix ?? config.Suffix ?? '')
  68 + const decRaw = config.decimal ?? config.Decimal
  69 + const decimal = typeof decRaw === 'number' ? decRaw : Number(decRaw)
  70 + const numericValue = Number(rawText)
  71 + const value =
  72 + !Number.isNaN(numericValue) && Number.isFinite(numericValue) && Number(decimal) >= 0
  73 + ? numericValue.toFixed(Number(decimal))
  74 + : rawText
  75 + return `${prefix}${value}${suffix}`
  76 +}
  77 +
  78 +/**
  79 + * 原生快打插件对 TEXT_PRODUCT / TEXT_PRICE / DATE 等仍会按类型做绑定;重打时 dataJson 为空,
  80 + * 必须把接口/落库快照里已填好的展示值焙成 TEXT_STATIC,否则会出现第二行商品名、价格 0、日期成 format 等问题。
  81 + */
  82 +export function bakeReprintTemplateSnapshot (tmpl: SystemLabelTemplate): SystemLabelTemplate {
  83 + const elements = (tmpl.elements || []).map((el) => {
  84 + const vst = String(el.valueSourceType || '').toUpperCase()
  85 + const type = String(el.type || '').toUpperCase()
  86 + const cfg = { ...(el.config || {}) } as Record<string, any>
  87 +
  88 + const toStatic = (line: string): SystemTemplateElementBase => ({
  89 + ...el,
  90 + type: 'TEXT_STATIC',
  91 + valueSourceType: 'FIXED',
  92 + config: { ...cfg, text: line, Text: line },
  93 + })
  94 +
  95 + if (vst === 'FIXED') {
  96 + if (type === 'TEXT_PRODUCT' || type === 'TEXT_CATEGORY' || type === 'TEXT_LABEL_ID') {
  97 + const t = String(cfg.text ?? cfg.Text ?? '').trim()
  98 + if (t) return toStatic(t)
  99 + }
  100 + if (type === 'TEXT_PRICE') {
  101 + const raw = String(cfg.text ?? cfg.Text ?? '').trim()
  102 + if (raw) {
  103 + const line = formatPriceLineForBake(cfg, raw)
  104 + return {
  105 + ...el,
  106 + type: 'TEXT_STATIC',
  107 + valueSourceType: 'FIXED',
  108 + config: { ...cfg, text: line, Text: line, prefix: '', Prefix: '', suffix: '', Suffix: '' },
  109 + }
  110 + }
  111 + }
  112 + }
  113 +
  114 + if (vst === 'PRINT_INPUT') {
  115 + if (type === 'DATE' || type === 'TIME' || type === 'DURATION') {
  116 + const textVal = String(cfg.text ?? cfg.Text ?? '').trim()
  117 + if (textVal) return toStatic(textVal)
  118 + }
  119 + if (type === 'WEIGHT') {
  120 + const textVal = String(cfg.text ?? cfg.Text ?? '').trim()
  121 + const v = String(cfg.value ?? cfg.Value ?? '').trim()
  122 + const u = String(cfg.unit ?? cfg.Unit ?? '').trim()
  123 + const line =
  124 + textVal || (v && u ? (v.endsWith(u) ? v : `${v}${u}`) : v || u)
  125 + if (line) return toStatic(line)
  126 + }
  127 + if (type === 'TEXT_STATIC') {
  128 + const inputType = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase()
  129 + if (inputType === 'number' || inputType === 'text') {
  130 + const t = String(cfg.text ?? cfg.Text ?? '').trim()
  131 + if (t) return toStatic(t)
  132 + }
  133 + if (
  134 + inputType === 'options' ||
  135 + cfg.multipleOptionId ||
  136 + cfg.MultipleOptionId
  137 + ) {
  138 + const rawText = String(cfg.text ?? cfg.Text ?? '').trim()
  139 + const sel = cfg.selectedOptionValues ?? cfg.SelectedOptionValues
  140 + const joined =
  141 + Array.isArray(sel) && sel.length ? sel.map((x: unknown) => String(x)).join(', ') : ''
  142 + let line = rawText
  143 + if (!line && joined) {
  144 + const dictLabel = String(
  145 + cfg.multipleOptionName ?? cfg.MultipleOptionName ?? 'Options',
  146 + ).trim()
  147 + const prefix = String(cfg.prefix ?? cfg.Prefix ?? '').trim()
  148 + line = prefix ? `${prefix}${joined}` : `${dictLabel}: ${joined}`
  149 + }
  150 + if (line) return toStatic(line)
  151 + }
  152 + }
  153 + }
  154 +
  155 + return el
  156 + })
  157 + return { ...tmpl, elements }
  158 +}
  159 +
  160 +/**
  161 + * 接口可能返回两类 renderConfigJson:
  162 + * 1)与设计器 elements[] 单项同构(含 type、x、y、config)
  163 + * 2)仅画布 config 快照(只有 text、fontSize 等),无 type、无坐标 —— 若直接当 element 传入,
  164 + * normalizeLabelTemplateFromPreviewApi 只读 e.config,会得到空 config,打印全空。
  165 + */
  166 +function inferElementTypeFromPrintSnapshotConfig (cfg: Record<string, unknown>): string {
  167 + const inputType = String(cfg.inputType ?? '').toLowerCase()
  168 + const hasUnit = cfg.unit != null || cfg.Unit != null
  169 + const hasValue = cfg.value != null || cfg.Value != null
  170 + if (hasUnit && hasValue) return 'WEIGHT'
  171 +
  172 + if (
  173 + typeof cfg.decimal === 'number' &&
  174 + ('prefix' in cfg || 'suffix' in cfg)
  175 + ) {
  176 + return 'TEXT_PRICE'
  177 + }
  178 +
  179 + if (cfg.format != null && inputType === 'datetime') return 'DATE'
  180 +
  181 + return 'TEXT_STATIC'
  182 +}
  183 +
  184 +function applyRenderValueToSnapshotConfig (
  185 + cfg: Record<string, unknown>,
  186 + elementType: string,
  187 + renderValue: string | null | undefined
  188 +): void {
  189 + if (renderValue === undefined || renderValue === null) return
  190 + const t = elementType.toUpperCase()
  191 + const s = String(renderValue)
  192 + if (t === 'WEIGHT') {
  193 + cfg.value = s
  194 + cfg.Value = s
  195 + return
  196 + }
  197 + if (t === 'DATE' || t === 'TIME') {
  198 + cfg.text = s
  199 + cfg.Text = s
  200 + return
  201 + }
  202 + cfg.text = s
  203 + cfg.Text = s
  204 +}
  205 +
  206 +function pageWidthPxFromPrintLogRow (row: PrintLogItemDto): number {
  207 + const size = parseLabelSizeText(row.labelSizeText ?? null)
  208 + const w = size?.width ?? 2
  209 + return Math.max(96, Math.round(w * 96))
  210 +}
  211 +
  212 +/**
  213 + * 将 printDataList 单项转为 normalize 可用的 element;扁平 config 会包进 config 并补全坐标(纵向堆叠)。
  214 + */
  215 +function elementFromPrintDataItem (
  216 + item: PrintLogDataItemDto,
  217 + index: number,
  218 + pageWidthPx: number
  219 +): Record<string, unknown> {
  220 + const raw =
  221 + item.renderConfigJson ?? (item as unknown as { RenderConfigJson?: unknown }).RenderConfigJson
  222 + if (raw == null) {
  223 + throw new Error('Missing renderConfigJson')
  224 + }
  225 +
  226 + let obj: Record<string, unknown>
  227 + if (typeof raw === 'string') {
  228 + try {
  229 + obj = JSON.parse(raw) as Record<string, unknown>
  230 + } catch {
  231 + throw new Error('Invalid element JSON')
  232 + }
  233 + } else if (typeof raw === 'object' && !Array.isArray(raw)) {
  234 + obj = { ...(raw as Record<string, unknown>) }
  235 + } else {
  236 + throw new Error('Invalid renderConfigJson')
  237 + }
  238 +
  239 + const hasElementEnvelope =
  240 + typeof obj.type === 'string' ||
  241 + typeof obj.Type === 'string' ||
  242 + typeof obj.elementType === 'string' ||
  243 + 'x' in obj ||
  244 + 'posX' in obj ||
  245 + 'config' in obj ||
  246 + 'ConfigJson' in obj ||
  247 + 'configJson' in obj
  248 +
  249 + if (hasElementEnvelope) {
  250 + const base = { ...obj }
  251 + if (item.elementId) {
  252 + base.id = item.elementId
  253 + base.Id = item.elementId
  254 + }
  255 + return base
  256 + }
  257 +
  258 + const cfg = { ...obj }
  259 + const inferredType = inferElementTypeFromPrintSnapshotConfig(cfg)
  260 + applyRenderValueToSnapshotConfig(cfg, inferredType, item.renderValue)
  261 +
  262 + const lineHeight = 40
  263 + const pad = 8
  264 + return {
  265 + id: item.elementId ?? `el-${index}`,
  266 + type: inferredType,
  267 + x: pad,
  268 + y: pad + index * lineHeight,
  269 + width: Math.max(40, pageWidthPx - pad * 2),
  270 + height: lineHeight,
  271 + rotation: 'horizontal',
  272 + border: 'none',
  273 + config: cfg,
  274 + zIndex: index,
  275 + orderNum: index,
  276 + }
  277 +}
  278 +
  279 +/**
  280 + * 重打快照:勿调用 overlayProductNameOnPreviewTemplate。
  281 + * 列表 printDataList 里 TEXT_PRODUCT 若丢字端,text 变空串会触发「占位」逻辑被整行替成 productName,出现两行 sandwich。
  282 + */
  283 +function overlayReprintResolvedFields (
  284 + tmpl: SystemLabelTemplate,
  285 + row: PrintLogItemDto,
  286 + data: LabelTemplateData
  287 +): SystemLabelTemplate {
  288 + const t = tmpl
  289 + const productName = nonEmptyDisplay(row.productName)
  290 + /** 勿含空串:接口拆包丢字端时 text 为空,若把 '' 当占位并整行换成 productName,会与第一行商品名重复成两个 sandwich */
  291 + const placeholders = new Set([
  292 + '文本',
  293 + 'text',
  294 + 'Text',
  295 + 'TEXT',
  296 + '名称',
  297 + 'name',
  298 + 'Name',
  299 + 'label',
  300 + 'Label',
  301 + ])
  302 +
  303 + const elements = (t.elements || []).map((el) => {
  304 + const type = String(el.type || '').toUpperCase()
  305 + const cfg = { ...(el.config || {}) } as Record<string, unknown>
  306 + const inputType = String(cfg.inputType ?? '').toLowerCase()
  307 +
  308 + if (type === 'TEXT_STATIC' || type === 'TEXT_PRODUCT') {
  309 + if (cfg.multipleOptionId || inputType === 'options') {
  310 + const rawText = String(cfg.text ?? cfg.Text ?? '').trim()
  311 + const sel = cfg.selectedOptionValues
  312 + const joined =
  313 + Array.isArray(sel) && sel.length ? sel.map((x) => String(x)).join(', ') : ''
  314 + /** 快照已存整行「Allergens: x, y」时勿改成仅选项值,否则缺行且缺前缀 */
  315 + if (rawText && (rawText.includes(joined) || (joined && rawText.length > joined.length))) {
  316 + return el
  317 + }
  318 + if (!rawText && joined) {
  319 + const dictLabel = String(cfg.multipleOptionName ?? cfg.MultipleOptionName ?? 'Options').trim()
  320 + const prefix = String(cfg.prefix ?? cfg.Prefix ?? '').trim()
  321 + const line = prefix ? `${prefix}${joined}` : `${dictLabel}: ${joined}`
  322 + return { ...el, config: { ...cfg, text: line } }
  323 + }
  324 + return el
  325 + }
  326 + }
  327 +
  328 + if ((type === 'TEXT_STATIC' || type === 'TEXT_PRODUCT') && productName) {
  329 + const raw = String(cfg.text ?? cfg.Text ?? '').trim()
  330 + const vstEl = String(el.valueSourceType || '').toUpperCase()
  331 + /** FIXED 的 TEXT_PRODUCT 多为标签类型/说明,占位「名称」等不应被商品名替换(避免出现两行 sandwich) */
  332 + if (type === 'TEXT_PRODUCT' && vstEl === 'FIXED') {
  333 + return el
  334 + }
  335 + if (placeholders.has(raw)) {
  336 + return {
  337 + ...el,
  338 + type: 'TEXT_PRODUCT',
  339 + config: { ...cfg, text: productName },
  340 + }
  341 + }
  342 + }
  343 +
  344 + if (type === 'DATE' && !String(cfg.text ?? cfg.Text ?? '').trim() && data.date) {
  345 + return { ...el, config: { ...cfg, text: String(data.date) } }
  346 + }
  347 + if (type === 'TIME' && !String(cfg.text ?? cfg.Text ?? '').trim() && data.time) {
  348 + return { ...el, config: { ...cfg, text: String(data.time) } }
  349 + }
  350 +
  351 + return el
  352 + })
  353 +
  354 + return { ...t, elements }
  355 +}
  356 +
  357 +/**
  358 + * 使用接口 10 返回的 `printDataList` 组装模板并走当前打印机(与预览同路径)。
  359 + */
  360 +function logReprintJson (label: string, data: unknown): void {
  361 + try {
  362 + console.log(`[Reprint] ${label}`, typeof data === 'string' ? data : JSON.stringify(data, null, 2))
  363 + } catch {
  364 + console.log(`[Reprint] ${label}`, data)
  365 + }
  366 +}
  367 +
  368 +export async function printFromPrintDataListRow (
  369 + row: PrintLogItemDto,
  370 + options: {
  371 + printQty?: number
  372 + onProgress?: (percent: number) => void
  373 + } = {}
  374 +): Promise<void> {
  375 + const list =
  376 + row.printDataList ??
  377 + (row as unknown as { PrintDataList?: PrintLogDataItemDto[] }).PrintDataList ??
  378 + []
  379 + if (!Array.isArray(list) || list.length === 0) {
  380 + throw new Error('No printDataList in record')
  381 + }
  382 +
  383 + const pageW = pageWidthPxFromPrintLogRow(row)
  384 + const elements = list.map((item, index) => elementFromPrintDataItem(item, index, pageW))
  385 + const size = parseLabelSizeText(row.labelSizeText ?? null)
  386 + const payload: Record<string, unknown> = {
  387 + id: row.labelId || row.labelCode || 'reprint',
  388 + name: 'Reprint',
  389 + unit: size?.unit ?? 'inch',
  390 + width: size?.width ?? 2,
  391 + height: size?.height ?? 2,
  392 + appliedLocation: 'ALL',
  393 + elements,
  394 + }
  395 +
  396 + console.log('[Reprint] 路径: printDataList 组装')
  397 + logReprintJson('printDataList 原始', list)
  398 + logReprintJson('组装的 template payload(normalize 前)', payload)
  399 +
  400 + let tmpl = normalizeLabelTemplateFromPreviewApi(payload)
  401 + if (!tmpl) {
  402 + throw new Error('Cannot build template from printDataList')
  403 + }
  404 +
  405 + const templateData = labelTemplateDataForSnapshotReprint()
  406 + tmpl = overlayReprintResolvedFields(tmpl, row, templateData)
  407 + tmpl = bakeReprintTemplateSnapshot(tmpl)
  408 +
  409 + tmpl = {
  410 + ...tmpl,
  411 + elements: sortElementsForPreview(tmpl.elements || []),
  412 + } as SystemLabelTemplate
  413 +
  414 + logReprintJson('bake + sort 后送打印机模板 tmpl', tmpl)
  415 +
  416 + rememberReprintEmittedTemplate(tmpl)
  417 + await printSystemTemplateForCurrentPrinter(
  418 + tmpl,
  419 + templateData,
  420 + { printQty: options.printQty ?? 1 },
  421 + options.onProgress
  422 + )
  423 +}
  424 +
  425 +/**
  426 + * 从列表接口 `renderTemplateJson` 或本地保存的整段请求体中取出与 `printInputJson` 同构的对象 JSON 字符串。
  427 + * 支持:`{ "printInputJson": { "elements": [...] } }` 或直接 `{ "elements": [...] }`。
  428 + */
  429 +export function extractPrintTemplateJsonForReprint (raw: string): string | null {
  430 + const s = raw.trim()
  431 + if (!s) return null
  432 + try {
  433 + const doc = JSON.parse(s) as Record<string, unknown>
  434 + const pi = doc.printInputJson ?? doc.PrintInputJson
  435 + if (pi != null && typeof pi === 'object' && !Array.isArray(pi)) {
  436 + return JSON.stringify(pi)
  437 + }
  438 + if (Array.isArray(doc.elements) || Array.isArray(doc.Elements)) {
  439 + return JSON.stringify(doc)
  440 + }
  441 + } catch {
  442 + return null
  443 + }
  444 + return null
  445 +}
  446 +
  447 +/**
  448 + * 打印日志重打入口:
  449 + * 1)本机按 taskId 存的合并快照(预览出纸成功时写入)——与接口 10 的 renderTemplateJson 解耦;
  450 + * 2)否则 `renderTemplateJson`(常为设计器占位,易错);
  451 + * 3)再否则 `printDataList`。
  452 + */
  453 +export async function printFromPrintLogRow (
  454 + row: PrintLogItemDto,
  455 + options: {
  456 + printQty?: number
  457 + onProgress?: (percent: number) => void
  458 + } = {}
  459 +): Promise<void> {
  460 + const r =
  461 + row.renderTemplateJson ??
  462 + (row as unknown as { RenderTemplateJson?: string | null }).RenderTemplateJson
  463 +
  464 + console.log('[Reprint] ========== 重复打印 JSON 调试 ==========')
  465 + console.log('[Reprint] taskId', row.taskId, 'labelCode', row.labelCode, 'productName', row.productName)
  466 + logReprintJson('renderTemplateJson(列表字段,可为空)', r ?? '(无)')
  467 +
  468 + const localSnap = getPrintTemplateSnapshotForTask(row.taskId)
  469 + if (localSnap) {
  470 + console.log('[Reprint] 优先使用本机存储的合并快照(与当次预览出纸一致),taskId=', row.taskId)
  471 + logReprintJson('本机快照 JSON', localSnap)
  472 + await printFromMergedTemplateJsonString(localSnap, row, options)
  473 + return
  474 + }
  475 +
  476 + if (typeof r === 'string' && r.trim()) {
  477 + const extracted = extractPrintTemplateJsonForReprint(r)
  478 + if (extracted) {
  479 + logReprintJson('extract 后用于打印的模板 JSON 字符串', extracted)
  480 + await printFromMergedTemplateJsonString(extracted, row, options)
  481 + return
  482 + }
  483 + console.warn('[Reprint] renderTemplateJson 存在但 extractPrintTemplateJsonForReprint 失败,回退 printDataList')
  484 + } else {
  485 + console.log('[Reprint] 无 renderTemplateJson,使用 printDataList')
  486 + }
  487 +
  488 + await printFromPrintDataListRow(row, options)
  489 +}
  490 +
  491 +/**
  492 + * 接口 11 返回的 `mergedTemplateJson`(与落库 PrintInputJson/RenderDataJson 同源),用于重打编排。
  493 + * 勿使用列表接口里的 `renderTemplateJson`/仅拆出来的 printDataList 代替完整模板,否则易缺坐标或缺用户输入快照。
  494 + */
  495 +export async function printFromMergedTemplateJsonString (
  496 + mergedTemplateJson: string,
  497 + row: PrintLogItemDto,
  498 + options: {
  499 + printQty?: number
  500 + onProgress?: (percent: number) => void
  501 + } = {}
  502 +): Promise<void> {
  503 + console.log('[Reprint] 路径: renderTemplateJson / merged 完整模板')
  504 + logReprintJson('mergedTemplateJson 原始字符串', mergedTemplateJson)
  505 +
  506 + let payload: unknown
  507 + try {
  508 + payload = JSON.parse(mergedTemplateJson) as unknown
  509 + } catch {
  510 + throw new Error('Invalid merged template JSON')
  511 + }
  512 + logReprintJson('JSON.parse 后的 payload', payload)
  513 +
  514 + let tmpl = normalizeLabelTemplateFromPreviewApi(payload)
  515 + if (!tmpl) {
  516 + throw new Error('Cannot parse merged template')
  517 + }
  518 + const templateData = labelTemplateDataForSnapshotReprint()
  519 + tmpl = overlayReprintResolvedFields(tmpl, row, templateData)
  520 + tmpl = bakeReprintTemplateSnapshot(tmpl)
  521 + tmpl = {
  522 + ...tmpl,
  523 + elements: sortElementsForPreview(tmpl.elements || []),
  524 + } as SystemLabelTemplate
  525 +
  526 + logReprintJson('bake + sort 后送打印机模板 tmpl', tmpl)
  527 + logReprintJson('templateData(快照重打为空对象)', templateData)
  528 +
  529 + rememberReprintEmittedTemplate(tmpl)
  530 + await printSystemTemplateForCurrentPrinter(
  531 + tmpl,
  532 + templateData,
  533 + { printQty: options.printQty ?? 1 },
  534 + options.onProgress
  535 + )
  536 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/printSnapshotStorage.ts 0 → 100644
  1 +/**
  2 + * 接口 10 返回的 renderTemplateJson 常为设计器占位(文本/名称/0.00),与当次出纸合并模板不一致。
  3 + * 本机按 taskId 存「与出纸同源」的合并模板 JSON,重打优先读取(仅前端,不改后端)。
  4 + */
  5 +const STORAGE_KEY = 'us_app_print_template_snapshot_v1'
  6 +const MAX_ENTRIES = 400
  7 +
  8 +type SnapshotStore = {
  9 + order: string[]
  10 + map: Record<string, string>
  11 +}
  12 +
  13 +function readStore (): SnapshotStore {
  14 + try {
  15 + const raw = uni.getStorageSync(STORAGE_KEY)
  16 + if (raw == null || raw === '') return { order: [], map: {} }
  17 + const p = typeof raw === 'string' ? JSON.parse(raw) : raw
  18 + if (!p || typeof p !== 'object') return { order: [], map: {} }
  19 + const order = Array.isArray(p.order) ? p.order.map((x: unknown) => String(x)) : []
  20 + const map = typeof p.map === 'object' && p.map && !Array.isArray(p.map) ? (p.map as Record<string, string>) : {}
  21 + return { order, map }
  22 + } catch {
  23 + return { order: [], map: {} }
  24 + }
  25 +}
  26 +
  27 +function writeStore (s: SnapshotStore) {
  28 + try {
  29 + uni.setStorageSync(STORAGE_KEY, JSON.stringify(s))
  30 + } catch {
  31 + /* 存储满或不可用则跳过 */
  32 + }
  33 +}
  34 +
  35 +function trimStore (s: SnapshotStore): SnapshotStore {
  36 + let { order, map } = s
  37 + while (order.length > MAX_ENTRIES) {
  38 + const k = order.shift()
  39 + if (k && map[k] != null) delete map[k]
  40 + }
  41 + return { order, map }
  42 +}
  43 +
  44 +/** 保存与本次出纸一致的合并模板 JSON(与 preview 落库 printInputJson 同构根对象) */
  45 +export function savePrintTemplateSnapshotForTask (taskId: string, mergedTemplateJson: string): void {
  46 + const id = String(taskId || '').trim()
  47 + if (!id || !mergedTemplateJson.trim()) return
  48 + const s = readStore()
  49 + const nextMap = { ...s.map, [id]: mergedTemplateJson }
  50 + let order = s.order.filter((k) => k !== id)
  51 + order.push(id)
  52 + writeStore(trimStore({ order, map: nextMap }))
  53 +}
  54 +
  55 +export function getPrintTemplateSnapshotForTask (taskId: string): string | null {
  56 + const id = String(taskId || '').trim()
  57 + if (!id) return null
  58 + const s = readStore()
  59 + const v = s.map[id]
  60 + return typeof v === 'string' && v.trim() ? v : null
  61 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/reprintFromMergedTemplate.ts 0 → 100644
  1 +import {
  2 + normalizeLabelTemplateFromPreviewApi,
  3 + sortElementsForPreview,
  4 +} from './labelPreview/normalizePreviewTemplate'
  5 +import { printSystemTemplateForCurrentPrinter } from './print/manager/printerManager'
  6 +import type { SystemLabelTemplate } from './print/types/printer'
  7 +
  8 +/**
  9 + * 将接口 11 返回的 `mergedTemplateJson`(或历史落库的合并模板 JSON 字符串)交给当前打印机打印。
  10 + */
  11 +export async function printMergedTemplateJsonString (
  12 + mergedJson: string,
  13 + options: {
  14 + printQty?: number
  15 + onProgress?: (percent: number) => void
  16 + } = {}
  17 +): Promise<void> {
  18 + let raw: unknown
  19 + try {
  20 + raw = JSON.parse(mergedJson) as unknown
  21 + } catch {
  22 + throw new Error('Invalid print snapshot JSON')
  23 + }
  24 + let tmpl = normalizeLabelTemplateFromPreviewApi(raw)
  25 + if (!tmpl) {
  26 + throw new Error('Cannot parse label template from snapshot')
  27 + }
  28 + const sorted: SystemLabelTemplate = {
  29 + ...tmpl,
  30 + elements: sortElementsForPreview(tmpl.elements || []),
  31 + }
  32 + await printSystemTemplateForCurrentPrinter(
  33 + sorted,
  34 + {},
  35 + { printQty: options.printQty ?? 1 },
  36 + options.onProgress
  37 + )
  38 +}
... ...
美国版/Food Labeling Management App UniApp/src/utils/resolveMediaUrl.ts
... ... @@ -11,3 +11,22 @@ export function resolveMediaUrlForApp(stored: string | null | undefined): string
11 11 if (mediaOrigin) return `${mediaOrigin.replace(/\/$/, '')}${path}`
12 12 return buildApiUrl(path)
13 13 }
  14 +
  15 +/**
  16 + * 判断模板里存的字符串是否应按「图片」加载(含平台上传后的 /picture/ 路径)。
  17 + * 用于 QRCODE 等元素:data 可能是「要编码的 URL 文本」,也可能是「已上传的二维码图」路径。
  18 + */
  19 +export function storedValueLooksLikeImagePath(stored: string | null | undefined): boolean {
  20 + const s = (stored ?? '').trim()
  21 + if (!s) return false
  22 + const lower = s.toLowerCase()
  23 + if (lower.startsWith('data:image/')) return true
  24 + if (lower.startsWith('/picture/') || lower.startsWith('/static/')) return true
  25 + if (/\.(png|jpe?g|gif|webp|bmp)(\?|#|$)/i.test(s)) return true
  26 + if (/^https?:\/\//i.test(s)) {
  27 + if (/\/picture\//i.test(s)) return true
  28 + if (/\.(png|jpe?g|gif|webp|bmp)(\?|#|$)/i.test(s)) return true
  29 + return false
  30 + }
  31 + return false
  32 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/PrintLogDataItemDto.cs 0 → 100644
  1 +using System.Text.Json;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
  4 +
  5 +/// <summary>
  6 +/// 打印明细快照(接口 10,对应 fl_label_print_data 解析后的元素级信息)
  7 +/// </summary>
  8 +public class PrintLogDataItemDto
  9 +{
  10 + public string ElementId { get; set; } = string.Empty;
  11 +
  12 + public string RenderValue { get; set; } = string.Empty;
  13 +
  14 + /// <summary>
  15 + /// 元素完整 JSON(与 LabelTemplateElementDto / 编辑器 elements[] 项同构),供 App 重打组装模板。
  16 + /// </summary>
  17 + public JsonElement? RenderConfigJson { get; set; }
  18 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/PrintLogGetListInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
  2 +
  3 +/// <summary>
  4 +/// App 打印日志分页(接口 10)
  5 +/// </summary>
  6 +public class PrintLogGetListInputVo
  7 +{
  8 + /// <summary>当前门店 Id</summary>
  9 + public string LocationId { get; set; } = string.Empty;
  10 +
  11 + /// <summary>页码,从 1 开始</summary>
  12 + public int SkipCount { get; set; } = 1;
  13 +
  14 + /// <summary>每页条数</summary>
  15 + public int MaxResultCount { get; set; } = 20;
  16 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/PrintLogItemDto.cs 0 → 100644
  1 +using System.Collections.Generic;
  2 +
  3 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
  4 +
  5 +/// <summary>
  6 +/// 单条打印日志(接口 10)
  7 +/// </summary>
  8 +public class PrintLogItemDto
  9 +{
  10 + public string TaskId { get; set; } = string.Empty;
  11 +
  12 + public string BatchId { get; set; } = string.Empty;
  13 +
  14 + public int CopyIndex { get; set; }
  15 +
  16 + public string LabelId { get; set; } = string.Empty;
  17 +
  18 + public string LabelCode { get; set; } = string.Empty;
  19 +
  20 + public string? ProductId { get; set; }
  21 +
  22 + public string ProductName { get; set; } = "无";
  23 +
  24 + public string PrintedAt { get; set; } = string.Empty;
  25 +
  26 + public string OperatorName { get; set; } = string.Empty;
  27 +
  28 + public string LocationName { get; set; } = string.Empty;
  29 +
  30 + /// <summary>标签分类名(展示用,可为空)</summary>
  31 + public string? LabelCategoryName { get; set; }
  32 +
  33 + /// <summary>标签幅面/模板摘要(如 2"x2" Basic)</summary>
  34 + public string? LabelTemplateSummary { get; set; }
  35 +
  36 + /// <summary>标签幅面文案(与列表/预览 labelSizeText 一致,如 2.00x2.00inch)</summary>
  37 + public string? LabelSizeText { get; set; }
  38 +
  39 + /// <summary>标签种类名称(fl_label_type.TypeName)</summary>
  40 + public string? TypeName { get; set; }
  41 +
  42 + /// <summary>本次份打印内容元素快照(由 RenderDataJson 解析)</summary>
  43 + public List<PrintLogDataItemDto> PrintDataList { get; set; } = new();
  44 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintInputVo.cs
... ... @@ -36,15 +36,16 @@ public class UsAppLabelPrintInputVo
36 36 public DateTime? BaseTime { get; set; }
37 37  
38 38 /// <summary>
39   - /// 打印输入(用于 PRINT_INPUT 元素)
  39 + /// 打印数据(对齐《标签模块接口对接说明(10)》):
  40 + /// - App 推荐传:与平台导出 label-template-*.json 同构的<strong>合并后模板</strong>(含 elements[].config),便于落库后直接重打;
  41 + /// - 兼容旧客户端:扁平字典(key 对齐元素 inputKey),由服务端 PreviewAsync 解析生成 RenderDataJson。
40 42 /// </summary>
41   - public Dictionary<string, object?>? PrintInputJson { get; set; }
  43 + public JsonElement? PrintInputJson { get; set; }
42 44  
43 45 /// <summary>
44   - /// 可选:App 端合并后的完整模板快照(与平台导出 label-template JSON 同构,含 elements[].config)。
45   - /// 传入时明细表 RenderDataJson 优先存此快照,便于打印历史与出纸结果一致;未传时仍由服务端 PreviewAsync 生成。
  46 + /// 客户端幂等请求 Id(可选);重复相同值时由服务端决定是否直接返回首次结果(见接口文档)。
46 47 /// </summary>
47   - public JsonElement? TemplateSnapshot { get; set; }
  48 + public string? ClientRequestId { get; set; }
48 49  
49 50 /// <summary>
50 51 /// 打印机Id(可选,若业务需要追踪)
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelPrintOutputDto.cs
1 1 namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
2 2  
3 3 /// <summary>
4   -/// App 打印出参
  4 +/// App 打印出参(接口 9 / 11)
5 5 /// </summary>
6 6 public class UsAppLabelPrintOutputDto
7 7 {
8 8 public string TaskId { get; set; } = string.Empty;
9 9  
10 10 public int PrintQuantity { get; set; }
  11 +
  12 + public string? BatchId { get; set; }
  13 +
  14 + public List<string> TaskIds { get; set; } = new();
  15 +
  16 + /// <summary>
  17 + /// 供 App 本地 BLE 重打:合并模板 JSON 字符串(与接口 9 落库的 printInputJson 同构,含 elements[])。
  18 + /// </summary>
  19 + public string? MergedTemplateJson { get; set; }
11 20 }
12 21  
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/UsAppLabeling/UsAppLabelReprintInputVo.cs 0 → 100644
  1 +namespace FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
  2 +
  3 +/// <summary>
  4 +/// App 重新打印入参(接口 11)
  5 +/// </summary>
  6 +public class UsAppLabelReprintInputVo
  7 +{
  8 + public string LocationId { get; set; } = string.Empty;
  9 +
  10 + public string TaskId { get; set; } = string.Empty;
  11 +
  12 + public int PrintQuantity { get; set; } = 1;
  13 +
  14 + public string? ClientRequestId { get; set; }
  15 +
  16 + public string? PrinterId { get; set; }
  17 +
  18 + public string? PrinterMac { get; set; }
  19 +
  20 + public string? PrinterAddress { get; set; }
  21 +}
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/IServices/IUsAppLabelingAppService.cs
  1 +using FoodLabeling.Application.Contracts.Dtos.Common;
1 2 using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
2 3 using Volo.Abp.Application.Services;
3 4  
... ... @@ -22,4 +23,14 @@ public interface IUsAppLabelingAppService : IApplicationService
22 23 /// App 打印:创建打印任务并落库打印明细(fl_label_print_task / fl_label_print_data)
23 24 /// </summary>
24 25 Task<UsAppLabelPrintOutputDto> PrintAsync(UsAppLabelPrintInputVo input);
  26 +
  27 + /// <summary>
  28 + /// 接口 10:当前账号在当前门店的打印日志分页
  29 + /// </summary>
  30 + Task<PagedResultWithPageDto<PrintLogItemDto>> GetPrintLogListAsync(PrintLogGetListInputVo input);
  31 +
  32 + /// <summary>
  33 + /// 接口 11:按历史任务重打并落库,返回新任务信息及可本地打印的模板 JSON
  34 + /// </summary>
  35 + Task<UsAppLabelPrintOutputDto> ReprintAsync(UsAppLabelReprintInputVo input);
25 36 }
... ...
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Services/UsAppLabelingAppService.cs
... ... @@ -4,11 +4,16 @@ using System.Globalization;
4 4 using System.Linq;
5 5 using System.Text.Json;
6 6 using System.Threading.Tasks;
  7 +using FoodLabeling.Application.Contracts.Dtos.Common;
7 8 using FoodLabeling.Application.Contracts.Dtos.Label;
  9 +using FoodLabeling.Application.Contracts.Dtos.LabelTemplate;
8 10 using FoodLabeling.Application.Contracts.Dtos.UsAppLabeling;
9 11 using FoodLabeling.Application.Contracts.IServices;
  12 +using FoodLabeling.Application.Helpers;
10 13 using FoodLabeling.Application.Services.DbModels;
  14 +using FoodLabeling.Domain.Entities;
11 15 using Microsoft.AspNetCore.Authorization;
  16 +using Microsoft.AspNetCore.Mvc;
12 17 using SqlSugar;
13 18 using Volo.Abp;
14 19 using Volo.Abp.Application.Services;
... ... @@ -388,35 +393,54 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
388 393 throw new UserFriendlyException("该标签不属于当前门店");
389 394 }
390 395  
391   - var printInputJsonStr = input.PrintInputJson is null
392   - ? null
393   - : JsonSerializer.Serialize(input.PrintInputJson);
394   -
  396 + string? printInputJsonStr = null;
395 397 string renderDataJsonStr;
396   - var snapshotOk = false;
397   - if (input.TemplateSnapshot.HasValue)
  398 + var templateSnapshotOk = false;
  399 +
  400 + if (input.PrintInputJson.HasValue)
398 401 {
399   - var snapEl = input.TemplateSnapshot.Value;
400   - if (snapEl.ValueKind == JsonValueKind.Object
401   - && snapEl.TryGetProperty("elements", out var elArr)
  402 + var piRoot = input.PrintInputJson.Value;
  403 + if (piRoot.ValueKind == JsonValueKind.Object
  404 + && piRoot.TryGetProperty("elements", out var elArr)
402 405 && elArr.ValueKind == JsonValueKind.Array)
403 406 {
404   - // App 与出纸一致的合并模板(label-template 同构),供打印历史/重打直接使用
405   - renderDataJsonStr = snapEl.GetRawText();
406   - snapshotOk = true;
  407 + // App 传入整份合并模板(与 label-template JSON 同构):落库 printInputJson / renderDataJson 均存同一份,供重打
  408 + printInputJsonStr = piRoot.GetRawText();
  409 + renderDataJsonStr = printInputJsonStr;
  410 + templateSnapshotOk = true;
  411 + }
  412 + }
  413 +
  414 + Dictionary<string, object?>? flatPrintInput = null;
  415 + if (!templateSnapshotOk && input.PrintInputJson.HasValue)
  416 + {
  417 + var piFlat = input.PrintInputJson.Value;
  418 + if (piFlat.ValueKind == JsonValueKind.Object)
  419 + {
  420 + try
  421 + {
  422 + flatPrintInput = JsonSerializer.Deserialize<Dictionary<string, object?>>(piFlat.GetRawText());
  423 + }
  424 + catch
  425 + {
  426 + flatPrintInput = null;
  427 + }
407 428 }
408 429 }
409 430  
410   - if (!snapshotOk)
  431 + if (!templateSnapshotOk)
411 432 {
412 433 var resolvedTemplate = await _labelAppService.PreviewAsync(new LabelPreviewResolveInputVo
413 434 {
414 435 LabelCode = labelCode,
415 436 ProductId = input.ProductId?.Trim(),
416 437 BaseTime = input.BaseTime,
417   - PrintInputJson = input.PrintInputJson
  438 + PrintInputJson = flatPrintInput
418 439 });
419 440 renderDataJsonStr = JsonSerializer.Serialize(resolvedTemplate);
  441 + printInputJsonStr = input.PrintInputJson.HasValue
  442 + ? input.PrintInputJson.Value.GetRawText()
  443 + : null;
420 444 }
421 445  
422 446 var now = DateTime.Now;
... ... @@ -462,7 +486,421 @@ public class UsAppLabelingAppService : ApplicationService, IUsAppLabelingAppServ
462 486 return new UsAppLabelPrintOutputDto
463 487 {
464 488 TaskId = taskId,
465   - PrintQuantity = quantity
  489 + PrintQuantity = quantity,
  490 + BatchId = taskId,
  491 + TaskIds = new List<string> { taskId },
  492 + MergedTemplateJson = null
  493 + };
  494 + }
  495 +
  496 + /// <summary>
  497 + /// 接口 10:分页打印日志(当前用户 + 当前门店)
  498 + /// </summary>
  499 + [Authorize]
  500 + [HttpPost]
  501 + public virtual async Task<PagedResultWithPageDto<PrintLogItemDto>> GetPrintLogListAsync(PrintLogGetListInputVo input)
  502 + {
  503 + if (input == null)
  504 + {
  505 + throw new UserFriendlyException("入参不能为空");
  506 + }
  507 +
  508 + var locationId = input.LocationId?.Trim();
  509 + if (string.IsNullOrWhiteSpace(locationId))
  510 + {
  511 + throw new UserFriendlyException("门店Id不能为空");
  512 + }
  513 +
  514 + var userId = CurrentUser.Id?.ToString();
  515 + if (string.IsNullOrWhiteSpace(userId))
  516 + {
  517 + throw new UserFriendlyException("未登录");
  518 + }
  519 +
  520 + var pageIndex = input.SkipCount <= 0 ? 1 : input.SkipCount;
  521 + var pageSize = input.MaxResultCount <= 0 ? 20 : Math.Min(input.MaxResultCount, 200);
  522 +
  523 + RefAsync<int> total = 0;
  524 + var dataRows = await _dbContext.SqlSugarClient
  525 + .Queryable<FlLabelPrintDataDbEntity, FlLabelPrintTaskDbEntity>((d, t) => d.TaskId == t.Id)
  526 + .Where((d, t) => !d.IsDeleted && !t.IsDeleted)
  527 + .Where((d, t) => t.CreatorId == userId && t.LocationId == locationId)
  528 + .OrderBy((d, t) => d.CreationTime, OrderByType.Desc)
  529 + .Select((d, t) => d)
  530 + .ToPageListAsync(pageIndex, pageSize, total);
  531 +
  532 + string? locationDisplayName = null;
  533 + if (Guid.TryParse(locationId, out var locGuid))
  534 + {
  535 + var locRows = await _dbContext.SqlSugarClient.Queryable<LocationAggregateRoot>()
  536 + .Where(x => x.Id == locGuid && !x.IsDeleted)
  537 + .Select(x => x.LocationName)
  538 + .Take(1)
  539 + .ToListAsync();
  540 + locationDisplayName = locRows.FirstOrDefault();
  541 + }
  542 +
  543 + var operatorName = CurrentUser.Name?.Trim();
  544 + if (string.IsNullOrWhiteSpace(operatorName))
  545 + {
  546 + operatorName = CurrentUser.UserName?.Trim();
  547 + }
  548 + if (string.IsNullOrWhiteSpace(operatorName))
  549 + {
  550 + operatorName = "无";
  551 + }
  552 +
  553 + var taskIds = dataRows.Select(x => x.TaskId).Distinct().ToList();
  554 + var tasks = taskIds.Count == 0
  555 + ? new List<FlLabelPrintTaskDbEntity>()
  556 + : await _dbContext.SqlSugarClient.Queryable<FlLabelPrintTaskDbEntity>()
  557 + .Where(t => taskIds.Contains(t.Id))
  558 + .ToListAsync();
  559 + var taskMap = tasks.ToDictionary(x => x.Id, x => x);
  560 +
  561 + var labelCodes = tasks.Select(t => t.LabelCode).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct().ToList();
  562 + var labels = labelCodes.Count == 0
  563 + ? new List<FlLabelDbEntity>()
  564 + : await _dbContext.SqlSugarClient.Queryable<FlLabelDbEntity>()
  565 + .Where(l => !l.IsDeleted && labelCodes.Contains(l.LabelCode))
  566 + .ToListAsync();
  567 + var labelByCode = labels.GroupBy(x => x.LabelCode).ToDictionary(g => g.Key, g => g.First());
  568 +
  569 + var categoryIds = labels
  570 + .Select(x => x.LabelCategoryId)
  571 + .Where(x => !string.IsNullOrWhiteSpace(x))
  572 + .Select(x => x!.Trim())
  573 + .Distinct()
  574 + .ToList();
  575 + var categories = categoryIds.Count == 0
  576 + ? new List<FlLabelCategoryDbEntity>()
  577 + : await _dbContext.SqlSugarClient.Queryable<FlLabelCategoryDbEntity>()
  578 + .Where(c => !c.IsDeleted && categoryIds.Contains(c.Id))
  579 + .ToListAsync();
  580 + var catMap = categories.ToDictionary(x => x.Id, x => x);
  581 +
  582 + var templateIds = labels
  583 + .Select(x => x.TemplateId)
  584 + .Where(x => !string.IsNullOrWhiteSpace(x))
  585 + .Select(x => x!.Trim())
  586 + .Distinct()
  587 + .ToList();
  588 + var templates = templateIds.Count == 0
  589 + ? new List<FlLabelTemplateDbEntity>()
  590 + : await _dbContext.SqlSugarClient.Queryable<FlLabelTemplateDbEntity>()
  591 + .Where(tpl => !tpl.IsDeleted && templateIds.Contains(tpl.Id))
  592 + .ToListAsync();
  593 + var tplMap = templates.ToDictionary(x => x.Id, x => x);
  594 +
  595 + var labelTypeIds = labels
  596 + .Select(x => x.LabelTypeId)
  597 + .Where(x => !string.IsNullOrWhiteSpace(x))
  598 + .Select(x => x!.Trim())
  599 + .Distinct()
  600 + .ToList();
  601 + var labelTypes = labelTypeIds.Count == 0
  602 + ? new List<FlLabelTypeDbEntity>()
  603 + : await _dbContext.SqlSugarClient.Queryable<FlLabelTypeDbEntity>()
  604 + .Where(lt => !lt.IsDeleted && labelTypeIds.Contains(lt.Id))
  605 + .ToListAsync();
  606 + var typeMap = labelTypes.ToDictionary(x => x.Id, x => x);
  607 +
  608 + var productIds = tasks
  609 + .Select(t => t.ProductId)
  610 + .Where(x => !string.IsNullOrWhiteSpace(x))
  611 + .Select(x => x!.Trim())
  612 + .Distinct()
  613 + .ToList();
  614 + var products = productIds.Count == 0
  615 + ? new List<FlProductDbEntity>()
  616 + : await _dbContext.SqlSugarClient.Queryable<FlProductDbEntity>()
  617 + .Where(p => !p.IsDeleted && productIds.Contains(p.Id))
  618 + .ToListAsync();
  619 + var prodMap = products.ToDictionary(x => x.Id, x => x);
  620 +
  621 + var items = dataRows.Select(d =>
  622 + {
  623 + taskMap.TryGetValue(d.TaskId, out var t);
  624 + var lblId = "";
  625 + string? catName = null;
  626 + string? tplSummary = null;
  627 + string? labelSizeText = null;
  628 + string? typeName = null;
  629 + if (t != null && !string.IsNullOrWhiteSpace(t.LabelCode) && labelByCode.TryGetValue(t.LabelCode.Trim(), out var lbl))
  630 + {
  631 + lblId = lbl.Id;
  632 + if (!string.IsNullOrWhiteSpace(lbl.LabelCategoryId) && catMap.TryGetValue(lbl.LabelCategoryId.Trim(), out var c))
  633 + {
  634 + catName = string.IsNullOrWhiteSpace(c.CategoryName) ? "无" : c.CategoryName.Trim();
  635 + }
  636 + if (!string.IsNullOrWhiteSpace(lbl.LabelTypeId) && typeMap.TryGetValue(lbl.LabelTypeId.Trim(), out var lt))
  637 + {
  638 + typeName = string.IsNullOrWhiteSpace(lt.TypeName) ? null : lt.TypeName.Trim();
  639 + }
  640 + if (!string.IsNullOrWhiteSpace(lbl.TemplateId) && tplMap.TryGetValue(lbl.TemplateId.Trim(), out var tpl))
  641 + {
  642 + tplSummary = $"{tpl.Width}x{tpl.Height}{tpl.Unit} {tpl.TemplateName}".Trim();
  643 + labelSizeText = string.Format(
  644 + CultureInfo.InvariantCulture,
  645 + "{0:0.00}x{1:0.00}{2}",
  646 + tpl.Width,
  647 + tpl.Height,
  648 + tpl.Unit);
  649 + }
  650 + }
  651 +
  652 + var productName = "无";
  653 + if (t != null && !string.IsNullOrWhiteSpace(t.ProductId) && prodMap.TryGetValue(t.ProductId.Trim(), out var p))
  654 + {
  655 + productName = string.IsNullOrWhiteSpace(p.ProductName) ? "无" : p.ProductName.Trim();
  656 + }
  657 +
  658 + return new PrintLogItemDto
  659 + {
  660 + TaskId = d.TaskId,
  661 + BatchId = d.TaskId,
  662 + CopyIndex = d.CopyIndex ?? 1,
  663 + LabelId = string.IsNullOrWhiteSpace(lblId) ? (t?.LabelCode ?? "") : lblId,
  664 + LabelCode = t?.LabelCode ?? "",
  665 + ProductId = t?.ProductId,
  666 + ProductName = productName,
  667 + PrintedAt = d.CreationTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture),
  668 + OperatorName = operatorName,
  669 + LocationName = string.IsNullOrWhiteSpace(locationDisplayName) ? "无" : locationDisplayName!,
  670 + LabelCategoryName = catName,
  671 + LabelTemplateSummary = tplSummary,
  672 + LabelSizeText = labelSizeText,
  673 + TypeName = typeName,
  674 + PrintDataList = BuildPrintDataListFromRenderJson(d.RenderDataJson)
  675 + };
  676 + }).ToList();
  677 +
  678 + var totalCount = total.Value;
  679 + var totalPages = pageSize <= 0 ? 0 : (int)Math.Ceiling(totalCount / (double)pageSize);
  680 +
  681 + return new PagedResultWithPageDto<PrintLogItemDto>
  682 + {
  683 + PageIndex = pageIndex,
  684 + PageSize = pageSize,
  685 + TotalCount = totalCount,
  686 + TotalPages = totalPages,
  687 + Items = items
  688 + };
  689 + }
  690 +
  691 + private static List<PrintLogDataItemDto> BuildPrintDataListFromRenderJson(string? renderDataJson)
  692 + {
  693 + var list = new List<PrintLogDataItemDto>();
  694 + if (string.IsNullOrWhiteSpace(renderDataJson))
  695 + {
  696 + return list;
  697 + }
  698 +
  699 + try
  700 + {
  701 + using var doc = JsonDocument.Parse(renderDataJson);
  702 + var root = doc.RootElement;
  703 + if (root.ValueKind == JsonValueKind.Object
  704 + && root.TryGetProperty("elements", out var elArr)
  705 + && elArr.ValueKind == JsonValueKind.Array)
  706 + {
  707 + foreach (var el in elArr.EnumerateArray())
  708 + {
  709 + if (el.ValueKind != JsonValueKind.Object)
  710 + {
  711 + continue;
  712 + }
  713 +
  714 + var id = el.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? string.Empty : string.Empty;
  715 + list.Add(new PrintLogDataItemDto
  716 + {
  717 + ElementId = id,
  718 + RenderValue = string.Empty,
  719 + RenderConfigJson = el.Clone()
  720 + });
  721 + }
  722 +
  723 + if (list.Count > 0)
  724 + {
  725 + return list;
  726 + }
  727 + }
  728 + }
  729 + catch
  730 + {
  731 + // 继续尝试强类型解析
  732 + }
  733 +
  734 + try
  735 + {
  736 + var preview = JsonSerializer.Deserialize<LabelTemplatePreviewDto>(renderDataJson);
  737 + if (preview?.Elements == null || preview.Elements.Count == 0)
  738 + {
  739 + return list;
  740 + }
  741 +
  742 + foreach (var el in preview.Elements)
  743 + {
  744 + var elJson = JsonSerializer.SerializeToElement(el);
  745 + list.Add(new PrintLogDataItemDto
  746 + {
  747 + ElementId = el.Id ?? string.Empty,
  748 + RenderValue = GetElementRenderValue(el),
  749 + RenderConfigJson = elJson
  750 + });
  751 + }
  752 + }
  753 + catch
  754 + {
  755 + // 历史数据或非预览结构时忽略
  756 + }
  757 +
  758 + return list;
  759 + }
  760 +
  761 + private static string GetElementRenderValue(LabelTemplateElementDto el)
  762 + {
  763 + try
  764 + {
  765 + if (el.ConfigJson is JsonElement je)
  766 + {
  767 + if (je.ValueKind == JsonValueKind.Object)
  768 + {
  769 + if (je.TryGetProperty("text", out var t))
  770 + {
  771 + return t.GetString() ?? string.Empty;
  772 + }
  773 + if (je.TryGetProperty("Text", out var t2))
  774 + {
  775 + return t2.GetString() ?? string.Empty;
  776 + }
  777 + }
  778 + }
  779 + }
  780 + catch
  781 + {
  782 + // ignore
  783 + }
  784 +
  785 + return string.Empty;
  786 + }
  787 +
  788 + /// <summary>
  789 + /// 接口 11:按历史任务重打并落库,返回可本地打印的合并模板 JSON
  790 + /// </summary>
  791 + [Authorize]
  792 + [UnitOfWork]
  793 + [HttpPost]
  794 + public virtual async Task<UsAppLabelPrintOutputDto> ReprintAsync(UsAppLabelReprintInputVo input)
  795 + {
  796 + if (input == null)
  797 + {
  798 + throw new UserFriendlyException("入参不能为空");
  799 + }
  800 +
  801 + var locationId = input.LocationId?.Trim();
  802 + if (string.IsNullOrWhiteSpace(locationId))
  803 + {
  804 + throw new UserFriendlyException("门店Id不能为空");
  805 + }
  806 +
  807 + var histTaskId = input.TaskId?.Trim();
  808 + if (string.IsNullOrWhiteSpace(histTaskId))
  809 + {
  810 + throw new UserFriendlyException("taskId不能为空");
  811 + }
  812 +
  813 + var userId = CurrentUser.Id?.ToString();
  814 + if (string.IsNullOrWhiteSpace(userId))
  815 + {
  816 + throw new UserFriendlyException("未登录");
  817 + }
  818 +
  819 + var taskRows = await _dbContext.SqlSugarClient.Queryable<FlLabelPrintTaskDbEntity>()
  820 + .Where(t => t.Id == histTaskId && !t.IsDeleted)
  821 + .Take(1)
  822 + .ToListAsync();
  823 + var task = taskRows.FirstOrDefault();
  824 + if (task == null)
  825 + {
  826 + throw new UserFriendlyException("打印任务不存在");
  827 + }
  828 +
  829 + if (!string.Equals(task.CreatorId?.Trim(), userId, StringComparison.Ordinal))
  830 + {
  831 + throw new UserFriendlyException("无权操作该打印任务");
  832 + }
  833 +
  834 + if (!string.Equals(task.LocationId?.Trim(), locationId, StringComparison.OrdinalIgnoreCase))
  835 + {
  836 + throw new UserFriendlyException("该任务不属于当前门店");
  837 + }
  838 +
  839 + var histDataRows = await _dbContext.SqlSugarClient.Queryable<FlLabelPrintDataDbEntity>()
  840 + .Where(d => d.TaskId == histTaskId && !d.IsDeleted)
  841 + .OrderBy(d => d.CopyIndex)
  842 + .Take(1)
  843 + .ToListAsync();
  844 + var histData = histDataRows.FirstOrDefault();
  845 + if (histData == null)
  846 + {
  847 + throw new UserFriendlyException("打印明细不存在");
  848 + }
  849 +
  850 + var mergedJson = !string.IsNullOrWhiteSpace(histData.PrintInputJson)
  851 + ? histData.PrintInputJson!
  852 + : histData.RenderDataJson;
  853 + if (string.IsNullOrWhiteSpace(mergedJson))
  854 + {
  855 + throw new UserFriendlyException("无法重打:历史打印数据为空");
  856 + }
  857 +
  858 + var qty = input.PrintQuantity <= 0 ? 1 : input.PrintQuantity;
  859 +
  860 + var now = DateTime.Now;
  861 + var newTaskId = _guidGenerator.Create().ToString();
  862 + var newTask = new FlLabelPrintTaskDbEntity
  863 + {
  864 + Id = newTaskId,
  865 + IsDeleted = false,
  866 + CreationTime = now,
  867 + CreatorId = userId,
  868 + ConcurrencyStamp = _guidGenerator.Create().ToString("N"),
  869 + LocationId = locationId,
  870 + LabelCode = task.LabelCode,
  871 + ProductId = task.ProductId,
  872 + LabelTypeId = task.LabelTypeId,
  873 + TemplateCode = task.TemplateCode,
  874 + PrintQuantity = qty,
  875 + BaseTime = task.BaseTime,
  876 + PrinterId = !string.IsNullOrWhiteSpace(input.PrinterId) ? input.PrinterId.Trim() : task.PrinterId,
  877 + PrinterMac = !string.IsNullOrWhiteSpace(input.PrinterMac) ? input.PrinterMac.Trim() : task.PrinterMac,
  878 + PrinterAddress = !string.IsNullOrWhiteSpace(input.PrinterAddress) ? input.PrinterAddress.Trim() : task.PrinterAddress
  879 + };
  880 + await _dbContext.SqlSugarClient.Insertable(newTask).ExecuteCommandAsync();
  881 +
  882 + var dataRows = Enumerable.Range(1, qty).Select(i => new FlLabelPrintDataDbEntity
  883 + {
  884 + Id = _guidGenerator.Create().ToString(),
  885 + IsDeleted = false,
  886 + CreationTime = now,
  887 + CreatorId = userId,
  888 + ConcurrencyStamp = _guidGenerator.Create().ToString("N"),
  889 + TaskId = newTaskId,
  890 + CopyIndex = i,
  891 + PrintInputJson = histData.PrintInputJson,
  892 + RenderDataJson = histData.RenderDataJson
  893 + }).ToList();
  894 +
  895 + await _dbContext.SqlSugarClient.Insertable(dataRows).ExecuteCommandAsync();
  896 +
  897 + return new UsAppLabelPrintOutputDto
  898 + {
  899 + TaskId = newTaskId,
  900 + PrintQuantity = qty,
  901 + BatchId = newTaskId,
  902 + TaskIds = new List<string> { newTaskId },
  903 + MergedTemplateJson = mergedJson
466 904 };
467 905 }
468 906  
... ...
美国版/Food Labeling Management Platform/build/assets/index-BaZIqfDW.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-rOdDFGrB.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
... ... @@ -5,7 +5,7 @@
5 5 <meta charset="UTF-8" />
6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 7 <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-BaZIqfDW.js"></script>
  8 + <script type="module" crossorigin src="/assets/index-rOdDFGrB.js"></script>
9 9 <link rel="stylesheet" crossorigin href="/assets/index-Dc47WtG1.css">
10 10 </head>
11 11  
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx
... ... @@ -17,7 +17,7 @@ import { getLabelTemplate, updateLabelTemplate } from &#39;../../services/labelTempl
17 17 import { getProducts } from '../../services/productService';
18 18 import { getLabelTypes } from '../../services/labelTypeService';
19 19 import { skipCountForPage } from '../../lib/paginationQuery';
20   -import type { ElementType, LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate';
  20 +import type { LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate';
21 21 import {
22 22 appliedLocationToEditor,
23 23 dataEntryColumnLabel,
... ... @@ -44,23 +44,33 @@ function newRowId(): string {
44 44 }
45 45 }
46 46  
  47 +/** 模板录入表:图片与二维码(及名称含 qrcode 的控件)用上传组件,预览区固定 100×100 */
  48 +const DATA_ENTRY_IMAGE_BOX =
  49 + 'h-[100px] w-[100px] min-h-[100px] min-w-[100px] max-h-[100px] max-w-[100px] shrink-0 aspect-auto';
  50 +
  51 +function dataEntryUsesImageUpload(element: LabelElement): boolean {
  52 + if (element.type === 'IMAGE' || element.type === 'QRCODE') return true;
  53 + const n = (element.elementName ?? '').trim().toLowerCase();
  54 + return n.includes('qrcode');
  55 +}
  56 +
47 57 function DataEntryValueCell({
48   - elementType,
  58 + element,
49 59 value,
50 60 onValueChange,
51 61 }: {
52   - elementType: ElementType;
  62 + element: LabelElement;
53 63 value: string;
54 64 onValueChange: (next: string) => void;
55 65 }) {
56   - if (elementType === 'IMAGE') {
  66 + if (dataEntryUsesImageUpload(element)) {
57 67 return (
58 68 <ImageUrlUpload
59 69 value={value}
60 70 onChange={onValueChange}
61 71 uploadSubDir="label-template-data"
62 72 oneImageOnly
63   - boxClassName="max-w-[160px]"
  73 + boxClassName={DATA_ENTRY_IMAGE_BOX}
64 74 hint="Upload stores full URL/path for save."
65 75 />
66 76 );
... ... @@ -381,7 +391,7 @@ export function LabelTemplateDataEntryView({
381 391 {printFields.map((f) => (
382 392 <TableCell key={f.id} className="align-top py-2">
383 393 <DataEntryValueCell
384   - elementType={f.type}
  394 + element={f}
385 395 value={row.fieldValues[f.id] ?? ''}
386 396 onValueChange={(v) => setFieldValue(row.id, f.id, v)}
387 397 />
... ...
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
... ... @@ -238,7 +238,7 @@ export function ProductsView() {
238 238 list = list.filter((p) => allowed.has(p.id));
239 239 }
240 240 if (categoryFilter !== "all") {
241   - list = list.filter((p) => (p.categoryName ?? "").trim() === categoryFilter);
  241 + list = list.filter((p) => (p.categoryId ?? "").trim() === categoryFilter);
242 242 }
243 243 const t = list.length;
244 244 setTotal(t);
... ... @@ -350,11 +350,11 @@ export function ProductsView() {
350 350 [locations],
351 351 );
352 352  
353   - const categoryNameOptions = useMemo(
  353 + const categorySelectOptions = useMemo(
354 354 () =>
355 355 productCategoriesCatalog
356 356 .map((c) => ({
357   - value: (c.categoryName ?? c.categoryCode ?? c.id ?? "").trim(),
  357 + value: (c.id ?? "").trim(),
358 358 label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id),
359 359 }))
360 360 .filter((o) => o.value),
... ... @@ -434,7 +434,7 @@ export function ProductsView() {
434 434 </SelectTrigger>
435 435 <SelectContent>
436 436 <SelectItem value="all">All Categories</SelectItem>
437   - {categoryNameOptions.map((o) => (
  437 + {categorySelectOptions.map((o) => (
438 438 <SelectItem key={o.value} value={o.value}>
439 439 {o.label}
440 440 </SelectItem>
... ... @@ -861,7 +861,7 @@ export function ProductsView() {
861 861 }}
862 862 editing={editingProduct}
863 863 locationOptions={locationOptions}
864   - categoryOptions={categoryNameOptions}
  864 + categoryOptions={categorySelectOptions}
865 865 locationMap={locationMap}
866 866 onSaved={() => {
867 867 refresh();
... ... @@ -925,7 +925,7 @@ function ProductFormDialog({
925 925 const [submitting, setSubmitting] = useState(false);
926 926 const [productCode, setProductCode] = useState("");
927 927 const [productName, setProductName] = useState("");
928   - const [categoryName, setCategoryName] = useState("");
  928 + const [categoryId, setCategoryId] = useState("");
929 929 const [productImageUrl, setProductImageUrl] = useState("");
930 930 const [state, setState] = useState(true);
931 931 const [locationId, setLocationId] = useState("");
... ... @@ -935,7 +935,7 @@ function ProductFormDialog({
935 935 if (editing) {
936 936 setProductCode(editing.productCode ?? "");
937 937 setProductName(editing.productName ?? "");
938   - setCategoryName((editing.categoryName ?? "").trim());
  938 + setCategoryId((editing.categoryId ?? "").trim());
939 939 setProductImageUrl(editing.productImageUrl ?? "");
940 940 setState(editing.state !== false);
941 941 const lids = locationMap.get(editing.id) ?? [];
... ... @@ -943,7 +943,7 @@ function ProductFormDialog({
943 943 } else {
944 944 setProductCode("");
945 945 setProductName("");
946   - setCategoryName("");
  946 + setCategoryId("");
947 947 setProductImageUrl("");
948 948 setState(true);
949 949 setLocationId("");
... ... @@ -963,7 +963,7 @@ function ProductFormDialog({
963 963 const body: ProductCreateInput = {
964 964 productCode: productCode.trim(),
965 965 productName: productName.trim(),
966   - categoryName: categoryName.trim() || null,
  966 + categoryId: categoryId.trim() || null,
967 967 productImageUrl: productImageUrl.trim() || null,
968 968 state,
969 969 };
... ... @@ -1021,10 +1021,10 @@ function ProductFormDialog({
1021 1021 </div>
1022 1022 </div>
1023 1023 <div className="space-y-2">
1024   - <Label>Category name</Label>
  1024 + <Label>Category</Label>
1025 1025 <SearchableSelect
1026   - value={categoryName}
1027   - onValueChange={setCategoryName}
  1026 + value={categoryId}
  1027 + onValueChange={setCategoryId}
1028 1028 options={categoryOptions}
1029 1029 placeholder="Select category (optional)"
1030 1030 searchPlaceholder="Search category…"
... ...
美国版/Food Labeling Management Platform/src/services/productService.ts
... ... @@ -27,6 +27,7 @@ function normalizeProductDto(raw: unknown): ProductDto {
27 27 id,
28 28 productCode: (r?.productCode ?? r?.ProductCode) as string | null | undefined,
29 29 productName: (r?.productName ?? r?.ProductName) as string | null | undefined,
  30 + categoryId: (r?.categoryId ?? r?.CategoryId) as string | null | undefined,
30 31 categoryName: (r?.categoryName ?? r?.CategoryName) as string | null | undefined,
31 32 productImageUrl: (r?.productImageUrl ?? r?.ProductImageUrl) as string | null | undefined,
32 33 state:
... ... @@ -73,7 +74,7 @@ export async function createProduct(input: ProductCreateInput): Promise&lt;ProductD
73 74 body: {
74 75 productCode: input.productCode,
75 76 productName: input.productName,
76   - categoryName: input.categoryName ?? null,
  77 + categoryId: input.categoryId?.trim() ? input.categoryId.trim() : null,
77 78 productImageUrl: input.productImageUrl ?? null,
78 79 state: input.state ?? true,
79 80 },
... ... @@ -88,7 +89,7 @@ export async function updateProduct(id: string, input: ProductUpdateInput): Prom
88 89 body: {
89 90 productCode: input.productCode,
90 91 productName: input.productName,
91   - categoryName: input.categoryName ?? null,
  92 + categoryId: input.categoryId?.trim() ? input.categoryId.trim() : null,
92 93 productImageUrl: input.productImageUrl ?? null,
93 94 state: input.state ?? true,
94 95 },
... ...
美国版/Food Labeling Management Platform/src/types/product.ts
... ... @@ -2,6 +2,7 @@ export type ProductDto = {
2 2 id: string;
3 3 productCode?: string | null;
4 4 productName?: string | null;
  5 + categoryId?: string | null;
5 6 categoryName?: string | null;
6 7 productImageUrl?: string | null;
7 8 state?: boolean | null;
... ... @@ -24,7 +25,7 @@ export type ProductGetListInput = {
24 25 export type ProductCreateInput = {
25 26 productCode: string;
26 27 productName: string;
27   - categoryName?: string | null;
  28 + categoryId?: string | null;
28 29 productImageUrl?: string | null;
29 30 state?: boolean;
30 31 };
... ...