diff --git a/标签模块接口对接说明(8).md b/标签模块接口对接说明.md index 8823a8f..b8553a7 100644 --- a/标签模块接口对接说明(8).md +++ b/标签模块接口对接说明.md @@ -835,6 +835,11 @@ curl -X GET "http://localhost:19001/api/app/us-app-labeling/labeling-tree?locati - `template`:`LabelTemplatePreviewDto` - `width` / `height` / `unit`:模板物理尺寸 - `elements[]`:元素数组(对齐前端 editor JSON:`id/elementName/type/x/y/width/height/rotation/border/zIndex/orderNum/config`) +- `templateProductDefaultValues`:`object | null` + - 来源:`fl_label_template_product_default.DefaultValuesJson` + - 命中条件:当前预览上下文的 `templateId + productId + labelTypeId` + - 建议结构:`{ "elementId": "默认值" }` + - 未命中时返回 `null`(向后兼容) `elements[].config` 内常用字段(示例): @@ -902,6 +907,7 @@ curl -X POST "http://localhost:19001/api/app/us-app-labeling/preview" \ | `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) | | `productId` | string | 否 | 打印用产品Id;不传则默认取该标签绑定的第一个产品(用于模板解析) | | `printQuantity` | number | 否 | 打印份数;`<=0` 按 1 处理 | +| `clientRequestId` | string | 否 | 客户端幂等请求Id;同一个值重复调用会直接返回首次创建的 `batchId/taskIds`,避免重复写库 用法:前端/客户端每次点击 Print 生成一个稳定的 clientRequestId(比如 uuid) | | `baseTime` | string | 否 | 业务基准时间(用于 DATE/TIME 元素计算) | | `printInputJson` | object | 否 | 打印输入(用于模板 PRINT_INPUT 元素),key 建议与模板元素 `inputKey` 对齐 | | `printerId` | string | 否 | 打印机Id(可选,用于追踪) | @@ -911,11 +917,11 @@ curl -X POST "http://localhost:19001/api/app/us-app-labeling/preview" \ #### 数据落库说明 - **任务表**:`fl_label_print_task` - - 插入 1 条任务记录(`locationId / labelCode / productId / labelTypeId / templateCode / printQuantity / baseTime / printer...` 等)。 + - **一份打印 = 一条任务**:当 `printQuantity = N` 时,后端会插入 **N 条任务记录**(同一次点击 Print 共享一个 `BatchId`,并记录 `CopyIndex=1..N`)。 + - 任务表会保存:本次打印的输入、命中的模板默认值、以及整份 resolved 后的模板快照 JSON,便于追溯/重打。 - **明细表**:`fl_label_print_data` - - 按 `printQuantity` 插入 N 条明细记录(`copyIndex = 1..N`)。 - - `printInputJson`:保存本次打印的原始输入(JSON 字符串)。 - - `renderDataJson`:保存本次解析后的模板预览结构(`LabelTemplatePreviewDto`,包含 resolved 后的 `elements[].config`),供追溯/重打使用。 + - **按组件写快照**:每个任务会按模板 `elements[]` 逐个插入明细记录(`ElementId/ElementName/RenderValue/RenderConfigJson`)。 + - 适用于按组件维度审计/统计/追溯。 > 模板解析的数据源来自 `fl_label_template` + `fl_label_template_element`,与预览接口一致。 @@ -923,8 +929,11 @@ curl -X POST "http://localhost:19001/api/app/us-app-labeling/preview" \ | 字段 | 类型 | 说明 | |---|---|---| -| `taskId` | string | 打印任务Id(用于后续查询/重打/统计) | +| `taskId` | string | 第 1 份打印任务Id(兼容旧逻辑) | | `printQuantity` | number | 实际写入的份数 | +| `batchId` | string | 本次点击 Print 的批次Id | +| `taskIds` | string[] | 本次生成的所有任务Id(长度=printQuantity) | + #### 错误与边界 @@ -959,3 +968,122 @@ curl -X POST "http://localhost:19001/api/app/us-app-labeling/print" \ -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"}}' ``` +--- + +## 接口 10:App 打印日志(当前登录账号 + 当前门店) + +**场景**:移动端“打印记录/历史”页面。只展示**当前登录账号**在**当前门店**打印的记录,便于追溯/重打。 + +### 10.1 分页获取打印日志 + +#### HTTP + +- **方法**:`POST`(与本模块其它复杂入参接口一致;若与 Swagger 不一致,**以 Swagger 为准**) +- **路径**:`/api/app/us-app-labeling/get-print-log-list` +- **鉴权**:需要登录(`Authorization: Bearer ...`) + +#### 入参(Body:`PrintLogGetListInputVo`) + +> 本项目分页约定:`skipCount` 表示 **页码(从 1 开始)**,不是 0 基 offset。 + +| 参数名(JSON) | 类型 | 必填 | 说明 | +|---|---|---|---| +| `locationId` | string | 是 | 当前门店Id(仅返回该门店记录) | +| `skipCount` | number | 否 | 页码,从 1 开始;默认 1 | +| `maxResultCount` | number | 否 | 每页条数;默认按后端/ABP 默认 | + +#### 过滤条件(后端固定逻辑) + +- `fl_label_print_task.CreatedBy == CurrentUser.Id` +- `fl_label_print_task.LocationId == locationId` +- 按时间倒序:`PrintedAt ?? CreationTime`(越新的越靠前) + +#### 出参(`PagedResultWithPageDto`) + +| 字段 | 类型 | 说明 | +|---|---|---| +| `pageIndex` | number | 当前页码(从 1 开始) | +| `pageSize` | number | 每页条数 | +| `totalCount` | number | 总条数 | +| `totalPages` | number | 总页数 | +| `items` | PrintLogItemDto[] | 列表 | + +`PrintLogItemDto`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `taskId` | string | 任务Id(fl_label_print_task.Id) | +| `batchId` | string | 批次Id(同一次点击 Print 共享) | +| `copyIndex` | number | 第几份(从 1 开始) | +| `labelId` | string | 标签Id | +| `labelCode` | string | 标签编码(来自 fl_label.LabelCode) | +| `productId` | string | 产品Id | +| `productName` | string | 产品名(来自 fl_product.ProductName;无则 “无”) | +| `typeName` | string | 标签类型名称(来自 fl_label_type.TypeName) | +| `labelSizeText` | string | 模板尺寸(宽高+单位,如 `2.00x2.00inch` / `6.00x4.00cm`) | +| `printDataList` | PrintLogDataItemDto[] | 本次打印内容快照(来自 fl_label_print_data,按 taskId 关联) | +| `printedAt` | string | 打印时间(PrintedAt ?? CreationTime) | +| `operatorName` | string | 操作人姓名(当前登录账号 Name) | +| `locationName` | string | 门店名称 | + +`PrintLogDataItemDto`: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `elementId` | string | 模板组件Id(fl_label_print_data.ElementId) | +| `renderValue` | string | 最终渲染值(fl_label_print_data.RenderValue) | +| `renderConfigJson` | object | 最终渲染配置(fl_label_print_data.RenderConfigJson 反序列化) | + +#### curl + +```bash +curl -X POST "http://localhost:19001/api/app/us-app-labeling/get-print-log-list" \ + -H "Authorization: " \ + -H "Content-Type: application/json" \ + -d '{"locationId":"11111111-1111-1111-1111-111111111111","skipCount":1,"maxResultCount":20}' +``` + +--- + +## 接口 11:App 重新打印(根据任务Id重打) + +**场景**:移动端“打印记录/历史”页面点击 **Reprint**。后端根据历史任务 `taskId` 创建一批新的打印任务与明细。 + +### 11.1 重打 + +#### HTTP + +- **方法**:`POST` +- **路径**:`/api/app/us-app-labeling/reprint`(若与 Swagger 不一致,**以 Swagger 为准**) +- **鉴权**:需要登录(`Authorization: Bearer ...`) + +#### 入参(Body:`UsAppLabelReprintInputVo`) + +| 参数名(JSON) | 类型 | 必填 | 说明 | +|---|---|---|---| +| `locationId` | string | 是 | 当前门店Id(后端校验历史任务必须属于该门店) | +| `taskId` | string | 是 | 历史打印任务Id(`fl_label_print_task.Id`) | +| `printQuantity` | number | 否 | 重新打印份数;`<=0` 按 1 处理;默认 1 | +| `clientRequestId` | string | 否 | 客户端幂等请求Id;同一个值重复调用会直接返回首次创建的 `batchId/taskIds`,避免重复写库 | +| `printerId` | string | 否 | 可选,覆盖历史任务的打印机Id | +| `printerMac` | string | 否 | 可选,覆盖历史任务的打印机MAC | +| `printerAddress` | string | 否 | 可选,覆盖历史任务的打印机地址 | + +#### 权限校验(后端固定逻辑) + +- 历史任务必须满足:`CreatedBy == CurrentUser.Id` +- 且 `LocationId == locationId` + +#### 出参(`UsAppLabelPrintOutputDto`) + +字段与接口 9 一致:`taskId / printQuantity / batchId / taskIds` + +#### curl + +```bash +curl -X POST "http://localhost:19001/api/app/us-app-labeling/reprint" \ + -H "Authorization: " \ + -H "Content-Type: application/json" \ + -d '{"locationId":"11111111-1111-1111-1111-111111111111","taskId":"3a205389-78dd-4750-51ab-720344c9f607","printQuantity":1}' +``` + diff --git a/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue b/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue index 88411ff..c2fbc93 100644 --- a/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue +++ b/美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue @@ -215,6 +215,7 @@ import { postUsAppLabelPrint, US_APP_LABEL_PRINT_PATH, } from '../../services/usAppLabeling' +import { savePrintTemplateSnapshotForTask } from '../../utils/printSnapshotStorage' import { applyTemplateProductDefaultValuesToTemplate, extractTemplateProductDefaultValuesFromPreviewPayload, @@ -547,6 +548,15 @@ const goBluetoothPage = () => { uni.navigateTo({ url: '/pages/labels/bluetooth' }) } +/** 接口 9 可选 clientRequestId(文档 10 幂等) */ +function createPrintClientRequestId (): string { + const c = typeof globalThis !== 'undefined' ? (globalThis as { crypto?: Crypto }).crypto : undefined + if (c && typeof c.randomUUID === 'function') { + return c.randomUUID() + } + return `print-${Date.now()}-${Math.random().toString(36).slice(2, 12)}` +} + const handlePrint = async () => { if (isPrinting.value || previewLoading.value || !systemTemplate.value) return @@ -660,17 +670,39 @@ const handlePrint = async () => { let printLogRequestBody: ReturnType = null try { const bt = getBluetoothConnection() + /** + * 接口 9 落库必须与本次出纸使用的合并模板完全一致(与 labelPrintJobPayload.template 同源), + * 避免另起 buildPrintPersistTemplateSnapshot(base) 与 computeMergedPreviewTemplate() 细微偏差, + * 导致库内缺用户输入的价签/过敏原/数字/日期,重打与预览不一致。 + */ + const persistTemplateDoc = JSON.parse( + JSON.stringify(labelPrintJobPayload.template) + ) as Record + printLogRequestBody = buildUsAppLabelPrintRequestBody({ locationId: getCurrentStoreId(), labelCode: labelCode.value, productId: productId.value || undefined, printQuantity: printQty.value, - printInputJson, - templateSnapshot: labelPrintJobPayload.template, + mergedTemplate: persistTemplateDoc, + clientRequestId: createPrintClientRequestId(), printerMac: bt?.deviceId || undefined, }) if (printLogRequestBody) { - await postUsAppLabelPrint(printLogRequestBody) + const printRes = await postUsAppLabelPrint(printLogRequestBody) + /** 本机快照:列表接口 renderTemplateJson 常为设计器占位,重打需与当次出纸合并模板一致 */ + try { + const tid = String( + (printRes as { taskId?: string })?.taskId + ?? (printRes as { TaskId?: string })?.TaskId + ?? '', + ).trim() + if (tid) { + savePrintTemplateSnapshotForTask(tid, JSON.stringify(persistTemplateDoc)) + } + } catch { + /* 忽略快照写入失败 */ + } } } catch (syncErr: unknown) { if (!isUsAppSessionExpiredError(syncErr)) { diff --git a/美国版/Food Labeling Management App UniApp/src/pages/more/print-log.vue b/美国版/Food Labeling Management App UniApp/src/pages/more/print-log.vue index 890da41..d319ffa 100644 --- a/美国版/Food Labeling Management App UniApp/src/pages/more/print-log.vue +++ b/美国版/Food Labeling Management App UniApp/src/pages/more/print-log.vue @@ -34,21 +34,32 @@ - + + + Loading… + + + No print records + + - + - {{ row.productName }} - {{ row.labelId }} + {{ row.productName || '无' }} + {{ shortRef(row) }} - {{ row.category }} - {{ row.template }} + {{ tagLabelSize(row) }} + {{ tagTypeName(row) }} @@ -57,15 +68,11 @@ - {{ row.printedBy }} + {{ row.operatorName || '无' }} - {{ row.location }} - - - - Expires {{ row.expiryDate }} + {{ row.locationName || '无' }} @@ -75,6 +82,12 @@ + + Loading more… + + + End of list + @@ -82,20 +95,22 @@ Product - Label ID + Ref + Label size + Type Printed At - Expires Action - {{ row.productName }} - {{ row.labelId }} + {{ row.productName || '无' }} + {{ shortRef(row) }} + {{ tagLabelSize(row) }} + {{ tagTypeName(row) }} {{ row.printedAt }} - {{ row.expiryDate }} @@ -103,6 +118,9 @@ + + Loading more… + @@ -112,20 +130,169 @@ + diff --git a/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx b/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx index 5e0bdd1..562bfd8 100644 --- a/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx +++ b/美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx @@ -17,7 +17,7 @@ import { getLabelTemplate, updateLabelTemplate } from '../../services/labelTempl import { getProducts } from '../../services/productService'; import { getLabelTypes } from '../../services/labelTypeService'; import { skipCountForPage } from '../../lib/paginationQuery'; -import type { ElementType, LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate'; +import type { LabelElement, LabelTemplateDto, LabelType, Unit } from '../../types/labelTemplate'; import { appliedLocationToEditor, dataEntryColumnLabel, @@ -44,23 +44,33 @@ function newRowId(): string { } } +/** 模板录入表:图片与二维码(及名称含 qrcode 的控件)用上传组件,预览区固定 100×100 */ +const DATA_ENTRY_IMAGE_BOX = + 'h-[100px] w-[100px] min-h-[100px] min-w-[100px] max-h-[100px] max-w-[100px] shrink-0 aspect-auto'; + +function dataEntryUsesImageUpload(element: LabelElement): boolean { + if (element.type === 'IMAGE' || element.type === 'QRCODE') return true; + const n = (element.elementName ?? '').trim().toLowerCase(); + return n.includes('qrcode'); +} + function DataEntryValueCell({ - elementType, + element, value, onValueChange, }: { - elementType: ElementType; + element: LabelElement; value: string; onValueChange: (next: string) => void; }) { - if (elementType === 'IMAGE') { + if (dataEntryUsesImageUpload(element)) { return ( ); @@ -381,7 +391,7 @@ export function LabelTemplateDataEntryView({ {printFields.map((f) => ( setFieldValue(row.id, f.id, v)} /> diff --git a/美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx b/美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx index 17dfac1..a2ad453 100755 --- a/美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx +++ b/美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx @@ -238,7 +238,7 @@ export function ProductsView() { list = list.filter((p) => allowed.has(p.id)); } if (categoryFilter !== "all") { - list = list.filter((p) => (p.categoryName ?? "").trim() === categoryFilter); + list = list.filter((p) => (p.categoryId ?? "").trim() === categoryFilter); } const t = list.length; setTotal(t); @@ -350,11 +350,11 @@ export function ProductsView() { [locations], ); - const categoryNameOptions = useMemo( + const categorySelectOptions = useMemo( () => productCategoriesCatalog .map((c) => ({ - value: (c.categoryName ?? c.categoryCode ?? c.id ?? "").trim(), + value: (c.id ?? "").trim(), label: toDisplay(c.categoryName ?? c.categoryCode ?? c.id), })) .filter((o) => o.value), @@ -434,7 +434,7 @@ export function ProductsView() { All Categories - {categoryNameOptions.map((o) => ( + {categorySelectOptions.map((o) => ( {o.label} @@ -861,7 +861,7 @@ export function ProductsView() { }} editing={editingProduct} locationOptions={locationOptions} - categoryOptions={categoryNameOptions} + categoryOptions={categorySelectOptions} locationMap={locationMap} onSaved={() => { refresh(); @@ -925,7 +925,7 @@ function ProductFormDialog({ const [submitting, setSubmitting] = useState(false); const [productCode, setProductCode] = useState(""); const [productName, setProductName] = useState(""); - const [categoryName, setCategoryName] = useState(""); + const [categoryId, setCategoryId] = useState(""); const [productImageUrl, setProductImageUrl] = useState(""); const [state, setState] = useState(true); const [locationId, setLocationId] = useState(""); @@ -935,7 +935,7 @@ function ProductFormDialog({ if (editing) { setProductCode(editing.productCode ?? ""); setProductName(editing.productName ?? ""); - setCategoryName((editing.categoryName ?? "").trim()); + setCategoryId((editing.categoryId ?? "").trim()); setProductImageUrl(editing.productImageUrl ?? ""); setState(editing.state !== false); const lids = locationMap.get(editing.id) ?? []; @@ -943,7 +943,7 @@ function ProductFormDialog({ } else { setProductCode(""); setProductName(""); - setCategoryName(""); + setCategoryId(""); setProductImageUrl(""); setState(true); setLocationId(""); @@ -963,7 +963,7 @@ function ProductFormDialog({ const body: ProductCreateInput = { productCode: productCode.trim(), productName: productName.trim(), - categoryName: categoryName.trim() || null, + categoryId: categoryId.trim() || null, productImageUrl: productImageUrl.trim() || null, state, }; @@ -1021,10 +1021,10 @@ function ProductFormDialog({
- +