Commit 43d16ca62e733f63d39e572b5c556cf8ee4dd302
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 "http://localhost:19001/api/app/us-app-labeling/preview" \ |
| 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 "http://localhost:19001/api/app/us-app-labeling/preview" \ |
| 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 "http://localhost:19001/api/app/us-app-labeling/print" \ |
| 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 = () => { |
| 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 () => { |
| 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 = () => { |
| 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 = () => { |
| 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 = () => { |
| 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 = () => { |
| 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 = () => { |
| 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<string, unknown>): Record<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<{ |
| 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<void> { |
| 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 '../../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<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 | }; | ... | ... |