Commit 5a192b24bd4eda6fe9b9048fea77a2e5ecd83812
1 parent
40929347
最新同步
Showing
85 changed files
with
6829 additions
and
1585 deletions
2026-07-09代码修改.md
0 → 100644
| 1 | +# 2026-07-09 代码修改 | |
| 2 | + | |
| 3 | +本文档说明 **2026-07-09** 对美国版标签(Label)模块的接口补充:**标签列表**与**批量导入**(Bulk Add Labels)。 | |
| 4 | + | |
| 5 | +--- | |
| 6 | + | |
| 7 | +## 一、背景与目标 | |
| 8 | + | |
| 9 | +| 项目 | 说明 | | |
| 10 | +|------|------| | |
| 11 | +| 业务场景 | Web 管理端 **Bulk Add Labels** 页面:同一模板下多行录入标签基本字段 + 模板数据字段后一次性保存 | | |
| 12 | +| 原行为 | 前端 `LabelsList.tsx` 对每行循环调用 `POST /api/app/label` 单条创建,模板数据再单独 `updateLabelTemplate` | | |
| 13 | +| 新行为 | 新增 `POST /api/app/label/batch-create`,一次提交多行;可选合并写入 `fl_label_template_product_default` | | |
| 14 | +| 列表接口 | `GET /api/app/label?SkipCount=1&MaxResultCount=10` 为标签分页列表(按标签维度分页,非 label-product 笛卡尔行) | | |
| 15 | + | |
| 16 | +--- | |
| 17 | + | |
| 18 | +## 二、标签列表 | |
| 19 | + | |
| 20 | +### 基本信息 | |
| 21 | + | |
| 22 | +| 项目 | 内容 | | |
| 23 | +|------|------| | |
| 24 | +| 方法 | `GET` | | |
| 25 | +| 路径 | `/api/app/label` | | |
| 26 | +| 鉴权 | Bearer Token | | |
| 27 | +| 示例 | `/api/app/label?SkipCount=1&MaxResultCount=10` | | |
| 28 | + | |
| 29 | +### 分页参数 | |
| 30 | + | |
| 31 | +| 参数 | 类型 | 说明 | | |
| 32 | +|------|------|------| | |
| 33 | +| `SkipCount` | `int` | 页码,**从 1 起**(非 0) | | |
| 34 | +| `MaxResultCount` | `int` | 每页条数 | | |
| 35 | + | |
| 36 | +### 筛选参数(Query) | |
| 37 | + | |
| 38 | +| 参数 | 说明 | | |
| 39 | +|------|------| | |
| 40 | +| `PartnerId` | 按 Company(`fl_partner.Id`)下门店集合筛选 | | |
| 41 | +| `GroupId` | 按 Region(`fl_group.Id`)筛选:命中 `fl_label_region`、`fl_label_location` 或 `AppliedRegionType=ALL` | | |
| 42 | +| `LocationId` | 按门店(`location.Id`)筛选;传则优先于 `GroupId` | | |
| 43 | +| `ProductId` | 按产品筛选(一个产品可对应多个标签) | | |
| 44 | +| `Keyword` | 关键词 | | |
| 45 | +| `LabelCategoryId` | 标签分类 | | |
| 46 | +| `LabelTypeId` | 标签类型(可选) | | |
| 47 | +| `TemplateCode` | 模板编码 | | |
| 48 | +| `State` | 启用状态 | | |
| 49 | + | |
| 50 | +### 出参结构 | |
| 51 | + | |
| 52 | +```json | |
| 53 | +{ | |
| 54 | + "pageIndex": 1, | |
| 55 | + "pageSize": 10, | |
| 56 | + "totalCount": 100, | |
| 57 | + "totalPages": 10, | |
| 58 | + "items": [ | |
| 59 | + { | |
| 60 | + "id": "LBL_xxx", | |
| 61 | + "labelName": "Turkey Breast", | |
| 62 | + "locationName": "Store A, Store B", | |
| 63 | + "locationIds": ["loc-id-1", "loc-id-2"], | |
| 64 | + "region": "Region A", | |
| 65 | + "regionIds": ["group-id-1"], | |
| 66 | + "groupIds": ["group-id-1"], | |
| 67 | + "appliedRegionType": "SPECIFIED", | |
| 68 | + "labelCategoryName": "Prep", | |
| 69 | + "productCategoryName": "Meat", | |
| 70 | + "products": "Turkey Breast", | |
| 71 | + "templateName": "3\"x6\" Nutrition Details", | |
| 72 | + "templateCode": "tpl_229mx5_mr05n3cq", | |
| 73 | + "labelTypeName": "Use By", | |
| 74 | + "state": true, | |
| 75 | + "lastEdited": "2026-07-09T10:00:00", | |
| 76 | + "hasError": false | |
| 77 | + } | |
| 78 | + ] | |
| 79 | +} | |
| 80 | +``` | |
| 81 | + | |
| 82 | +### 列表 curl 示例 | |
| 83 | + | |
| 84 | +```bash | |
| 85 | +# 登录(管理端) | |
| 86 | +curl -X POST "http://192.168.31.88:19001/api/app/account/login" \ | |
| 87 | + -H "Content-Type: application/json" \ | |
| 88 | + -d '{"userName":"sandi@123.com","password":"123456"}' | |
| 89 | + | |
| 90 | +# 标签列表第一页(10 条) | |
| 91 | +curl -G "http://192.168.31.88:19001/api/app/label" \ | |
| 92 | + -H "Authorization: Bearer {token}" \ | |
| 93 | + --data-urlencode "SkipCount=1" \ | |
| 94 | + --data-urlencode "MaxResultCount=10" | |
| 95 | +``` | |
| 96 | + | |
| 97 | +--- | |
| 98 | + | |
| 99 | +## 三、标签批量导入 | |
| 100 | + | |
| 101 | +### 基本信息 | |
| 102 | + | |
| 103 | +| 项目 | 内容 | | |
| 104 | +|------|------| | |
| 105 | +| 方法 | `POST` | | |
| 106 | +| 路径 | `/api/app/label/batch-create` | | |
| 107 | +| 鉴权 | Bearer Token | | |
| 108 | +| 对应页面 | Web 端 **Bulk Add Labels**(`LabelsList.tsx` → `LabelBulkAddPage`) | | |
| 109 | +| 单次上限 | 500 行 | | |
| 110 | + | |
| 111 | +### Body 顶层字段 | |
| 112 | + | |
| 113 | +| 字段 | 类型 | 必填 | 说明 | | |
| 114 | +|------|------|------|------| | |
| 115 | +| `templateCode` | `string` | 是 | 所有行共用的模板编码 | | |
| 116 | +| `items` | `array` | 是 | 导入行列表 | | |
| 117 | +| `saveTemplateProductDefaults` | `bool` | 否 | 是否合并写入模板 ProductDefaults,默认 `true` | | |
| 118 | + | |
| 119 | +### `items[]` 每行字段 | |
| 120 | + | |
| 121 | +#### 标签基本字段(与单条 `POST /api/app/label` 一致) | |
| 122 | + | |
| 123 | +| 字段 | 类型 | 必填 | 说明 | | |
| 124 | +|------|------|------|------| | |
| 125 | +| `labelName` | `string` | 是 | 标签名称 | | |
| 126 | +| `labelCategoryId` | `string` | 是 | 标签分类 Id | | |
| 127 | +| `labelTypeId` | `string` | 建议 | 标签类型 Id | | |
| 128 | +| `productIds` | `string[]` | 是 | 至少 1 个产品 | | |
| 129 | +| `locationIds` | `string[]` | 建议 | 适用门店(主字段) | | |
| 130 | +| `locationId` | `string` | 否 | 兼容单门店,与 `locationIds` 合并 | | |
| 131 | +| `appliedRegionType` | `string` | 否 | `ALL` / `SPECIFIED` | | |
| 132 | +| `regionIds` / `groupIds` | `string[]` | 条件 | `SPECIFIED` 时传 Region | | |
| 133 | +| `partnerId` / `partnerIds` | `string` / `string[]` | 否 | Company 范围 | | |
| 134 | +| `labelCode` | `string` | 否 | 不传则自动生成 `LBL_{guid}` | | |
| 135 | +| `labelInfoJson` | `object` | 否 | 标签级模板 JSON(通常走 ProductDefaults) | | |
| 136 | +| `state` | `bool` | 否 | 默认 `true` | | |
| 137 | + | |
| 138 | +#### 模板数据字段(对应 Bulk Add 表格列) | |
| 139 | + | |
| 140 | +| 字段 | 类型 | 说明 | | |
| 141 | +|------|------|------| | |
| 142 | +| `templateDefaultValues` | `object` | 已组装的 elementId → 值;**优先使用**,不再从下方字段推导 | | |
| 143 | +| `templateDataValues` | `object` | 文本类录入(如 Text、Barcode 手填值) | | |
| 144 | +| `templateDateOffsets` | `object` | 日期/时间/时长:`{ "el-id": { "unit": "Days", "value": "3" } }` | | |
| 145 | +| `nutritionByElementId` | `object` | 营养成分:`{ "el-nutrition": { "calories": "120" } }` | | |
| 146 | + | |
| 147 | +**模板数据落库规则** | |
| 148 | + | |
| 149 | +- `saveTemplateProductDefaults=true` 时:按 `productId + labelTypeId` 合并写入 `fl_label_template_product_default`(与前端 `saveTemplateDefaultsAfterLabels` 一致) | |
| 150 | +- 日期偏移存为 JSON 字符串:`{"unit":"Days","value":"3"}` | |
| 151 | +- Template 面板条码/二维码自动填充产品 `codeValue`;Label Name / Label Type 自动绑定行内 `labelName`、类型名 | |
| 152 | + | |
| 153 | +### 请求示例 | |
| 154 | + | |
| 155 | +```json | |
| 156 | +{ | |
| 157 | + "templateCode": "tpl_229mx5_mr05n3cq", | |
| 158 | + "saveTemplateProductDefaults": true, | |
| 159 | + "items": [ | |
| 160 | + { | |
| 161 | + "labelName": "Chicken Salad", | |
| 162 | + "labelCategoryId": "3a2228ca-42fa-5273-1105-3d8252ffdd99", | |
| 163 | + "labelTypeId": "3a2228e5-33c5-278e-a09f-47fd6e2ddbc7", | |
| 164 | + "locationIds": ["3a222878-b4c1-d27b-d437-93da14886350"], | |
| 165 | + "productIds": ["3a2228a8-6a51-6cc4-fee2-b77769766b33"], | |
| 166 | + "appliedRegionType": "SPECIFIED", | |
| 167 | + "regionIds": [], | |
| 168 | + "templateDataValues": { | |
| 169 | + "el-1782794752668-0gixa9y": "Fresh batch text", | |
| 170 | + "el-1782794771519-4360c0p": "123456789" | |
| 171 | + }, | |
| 172 | + "templateDateOffsets": { | |
| 173 | + "el-1782794481721-sdf7mvy": { "unit": "Days", "value": "3" } | |
| 174 | + }, | |
| 175 | + "state": true | |
| 176 | + }, | |
| 177 | + { | |
| 178 | + "labelName": "Turkey Wrap", | |
| 179 | + "labelCategoryId": "3a2228ca-42fa-5273-1105-3d8252ffdd99", | |
| 180 | + "labelTypeId": "3a2228e6-3ed0-b056-a08b-ea8b1eea46a0", | |
| 181 | + "locationIds": ["3a222878-b4c1-d27b-d437-93da14886350"], | |
| 182 | + "productIds": ["3a2228a8-6a51-6cc4-fee2-b77769766b33"], | |
| 183 | + "templateDataValues": { | |
| 184 | + "el-1782794752668-0gixa9y": "Ready to serve" | |
| 185 | + }, | |
| 186 | + "templateDateOffsets": { | |
| 187 | + "el-1782794481721-sdf7mvy": { "unit": "Days", "value": "5" } | |
| 188 | + } | |
| 189 | + } | |
| 190 | + ] | |
| 191 | +} | |
| 192 | +``` | |
| 193 | + | |
| 194 | +### 响应示例 | |
| 195 | + | |
| 196 | +```json | |
| 197 | +{ | |
| 198 | + "successCount": 2, | |
| 199 | + "failCount": 0, | |
| 200 | + "successItems": [ | |
| 201 | + { "rowNumber": 1, "labelCode": "LBL_abc...", "labelName": "Chicken Salad" }, | |
| 202 | + { "rowNumber": 2, "labelCode": "LBL_def...", "labelName": "Turkey Wrap" } | |
| 203 | + ], | |
| 204 | + "errors": [] | |
| 205 | +} | |
| 206 | +``` | |
| 207 | + | |
| 208 | +**部分成功示例**(第 2 行校验失败、第 1 行仍创建): | |
| 209 | + | |
| 210 | +```json | |
| 211 | +{ | |
| 212 | + "successCount": 1, | |
| 213 | + "failCount": 1, | |
| 214 | + "successItems": [ | |
| 215 | + { "rowNumber": 1, "labelCode": "LBL_abc...", "labelName": "Chicken Salad" } | |
| 216 | + ], | |
| 217 | + "errors": [ | |
| 218 | + { "rowNumber": 2, "labelName": "", "message": "标签名称不能为空" } | |
| 219 | + ] | |
| 220 | +} | |
| 221 | +``` | |
| 222 | + | |
| 223 | +### 批量导入 curl 示例 | |
| 224 | + | |
| 225 | +```bash | |
| 226 | +curl -X POST "http://192.168.31.88:19001/api/app/label/batch-create" \ | |
| 227 | + -H "Authorization: Bearer {token}" \ | |
| 228 | + -H "Content-Type: application/json" \ | |
| 229 | + -d '{ | |
| 230 | + "templateCode": "tpl_229mx5_mr05n3cq", | |
| 231 | + "saveTemplateProductDefaults": true, | |
| 232 | + "items": [ | |
| 233 | + { | |
| 234 | + "labelName": "Batch Test", | |
| 235 | + "labelCategoryId": "3a2228ca-42fa-5273-1105-3d8252ffdd99", | |
| 236 | + "labelTypeId": "3a2228e5-33c5-278e-a09f-47fd6e2ddbc7", | |
| 237 | + "locationIds": ["3a222878-b4c1-d27b-d437-93da14886350"], | |
| 238 | + "productIds": ["3a2228a8-6a51-6cc4-fee2-b77769766b33"], | |
| 239 | + "templateDataValues": { "el-1782794752668-0gixa9y": "Fresh" }, | |
| 240 | + "templateDateOffsets": { "el-1782794481721-sdf7mvy": { "unit": "Days", "value": "3" } } | |
| 241 | + } | |
| 242 | + ] | |
| 243 | + }' | |
| 244 | +``` | |
| 245 | + | |
| 246 | +--- | |
| 247 | + | |
| 248 | +## 四、与单条创建对比 | |
| 249 | + | |
| 250 | +| 能力 | `POST /api/app/label` | `POST /api/app/label/batch-create` | | |
| 251 | +|------|----------------------|-----------------------------------| | |
| 252 | +| 行数 | 1 条 | 最多 500 条 | | |
| 253 | +| 模板编码 | 每 Body 自带 | 顶层 `templateCode` 共用 | | |
| 254 | +| 模板数据 | 仅 `labelInfoJson` | 支持结构化模板字段 + ProductDefaults 合并 | | |
| 255 | +| 失败处理 | 整请求失败 | 行级错误收集,支持部分成功 | | |
| 256 | +| 返回值 | 标签详情 DTO | `successCount` / `failCount` / `errors` / `successItems` | | |
| 257 | + | |
| 258 | +--- | |
| 259 | + | |
| 260 | +## 五、代码改动摘要 | |
| 261 | + | |
| 262 | +| 文件 | 改动 | | |
| 263 | +|------|------| | |
| 264 | +| `ILabelAppService.cs` | 新增 `BatchCreateAsync` | | |
| 265 | +| `LabelAppService.cs` | 实现批量导入;逐行复用 `CreateAsync`;可选合并模板 ProductDefaults | | |
| 266 | +| `LabelBatchCreateInputVo.cs` | 批量入参 DTO | | |
| 267 | +| `LabelBatchCreateItemInputVo.cs` | 单行(基本字段 + 模板数据字段) | | |
| 268 | +| `LabelBatchDateOffsetInputVo.cs` | 日期偏移单位+数值 | | |
| 269 | +| `LabelBatchCreateResultDto.cs` | 批量结果 DTO | | |
| 270 | +| `LabelBatchTemplateDefaultsHelper.cs` | 组装/合并模板 ProductDefaults | | |
| 271 | + | |
| 272 | +--- | |
| 273 | + | |
| 274 | +## 六、部署与联调注意 | |
| 275 | + | |
| 276 | +1. `dotnet build` 编译 `FoodLabeling.Application` 与 `Yi.Abp.Web` 后**重启 API 进程**(未重启时 `batch-create` 会返回 405) | |
| 277 | +2. 管理端登录:`POST /api/app/account/login`(非 `/api/oauth/Login`) | |
| 278 | +3. 本地 Base URL 以 `appsettings.json` 中 `App:SelfUrl` 为准(当前示例为 `http://192.168.31.88:19001`) | |
| 279 | +4. 前端 `LabelsList.tsx` 的 `submit()` 仍循环调单条 `createLabel`;对接批量接口时需改为调用 `batch-create` | |
| 280 | + | |
| 281 | +--- | |
| 282 | + | |
| 283 | +## 七、相关文档 | |
| 284 | + | |
| 285 | +- 标签多门店 `locationIds`:`项目相关文档/6-23代码优化.md` | |
| 286 | +- 模板三维 scope:`项目相关文档/6-4代码优化.md` | ... | ... |
6-26代码优化.md deleted
| 1 | -# 6-26 代码优化 | |
| 2 | - | |
| 3 | -本文档说明 **2026-06-26** 对美国版标签模板 `contents` 字段的**数据来源澄清**与**编辑不生效**修复。 | |
| 4 | - | |
| 5 | ---- | |
| 6 | - | |
| 7 | -## 一、问题现象 | |
| 8 | - | |
| 9 | -| 项目 | 说明 | | |
| 10 | -|------|------| | |
| 11 | -| 接口 | `GET/PUT /api/app/label-template/{id}`(如 `tpl_c23gov_mqri4g5a`) | | |
| 12 | -| 期望 | `contents` = `"Nutrition Facts, Text (For Template)"`(模板列表 Contents 列展示) | | |
| 13 | -| 实际 | 返回/展示 `"nutritionfacts, text"` | | |
| 14 | -| 用户反馈 | 在编辑器修改 Element name 后保存,列表 Contents **没有变化** | | |
| 15 | - | |
| 16 | ---- | |
| 17 | - | |
| 18 | -## 二、`contents` 取自哪个字段? | |
| 19 | - | |
| 20 | -### 结论(优先级) | |
| 21 | - | |
| 22 | -| 优先级 | 来源 | 字段 | 说明 | | |
| 23 | -|--------|------|------|------| | |
| 24 | -| 1 | 编辑器右侧 **Element name** | `elements[].elementName` | 用户可编辑;保存时写入 `fl_label_template_element.ElementName` | | |
| 25 | -| 2 | 旧数据兼容(slug) | `elements[].typeAdd` | 当 `elementName` 为旧 slug(如 `nutritionfacts`、`text`)时,按 typeAdd 推导展示名 | | |
| 26 | -| 3 | 持久化缓存 | `fl_label_template.Contents` | 保存时写入;**列表以 element 实时汇总为准**,避免旧缓存覆盖新编辑 | | |
| 27 | - | |
| 28 | -### typeAdd 推导规则 | |
| 29 | - | |
| 30 | -`typeAdd` 形如 `{分组前缀}_{面板控件英文名}`: | |
| 31 | - | |
| 32 | -| typeAdd 示例 | 推导展示名 | | |
| 33 | -|--------------|------------| | |
| 34 | -| `label_Nutrition Facts` | `Nutrition Facts (For Label)` | | |
| 35 | -| `template_Text` | `Text (For Template)` | | |
| 36 | -| `auto_Company` | `Company (Entered Automatically)` | | |
| 37 | -| `print_Multiple Options` | `Multiple Options (Entered When Printing)` | | |
| 38 | - | |
| 39 | -**拼接格式**:`{控件面板名称} ({分组标题})` | |
| 40 | -分组标题与左侧 Elements 面板一致:`For Template` / `For Label` / `Entered Automatically` / `Entered When Printing`。 | |
| 41 | - | |
| 42 | -### 与 elementName 的关系 | |
| 43 | - | |
| 44 | -| 场景 | elementName 示例 | contents 单项 | | |
| 45 | -|------|------------------|---------------| | |
| 46 | -| 新拖入控件(修复后) | `Text (For Template)` | `Text (For Template)` | | |
| 47 | -| 旧模板 slug | `text` | `Text (For Template)`(由 typeAdd 推导) | | |
| 48 | -| 用户手动改 Element name | `My Custom Field` | `My Custom Field`(以用户输入为准) | | |
| 49 | - | |
| 50 | -**注意**:`elementName` 同时用于录入表表头;`contents` 是各控件展示名的逗号拼接,**不是** slug 字段。 | |
| 51 | - | |
| 52 | ---- | |
| 53 | - | |
| 54 | -## 三、根因说明 | |
| 55 | - | |
| 56 | -1. **新建控件时** `allocateElementName` 把 Element name 默认成 slug(`nutritionfacts`),与 UI 期望的「Nutrition Facts (For Label)」不一致。 | |
| 57 | -2. **列表 contents** 曾优先读 `fl_label_template.Contents` 缓存,保存后 element 已变但缓存仍是旧 slug 串。 | |
| 58 | -3. **列表回退逻辑** 直接读 `ElementName` 原文,slug 原样展示为 `nutritionfacts, text`。 | |
| 59 | - | |
| 60 | ---- | |
| 61 | - | |
| 62 | -## 四、修复内容 | |
| 63 | - | |
| 64 | -### 后端 | |
| 65 | - | |
| 66 | -| 文件 | 改动 | | |
| 67 | -|------|------| | |
| 68 | -| `LabelTemplateContentsHelper.cs` | `ResolveElementContentsLabel`:Element name 优先;slug 则 typeAdd 推导 | | |
| 69 | -| `LabelTemplateContentsHelper.cs` | `ResolveContentsMapFromElementsAsync`:列表按 element 表**实时**汇总 | | |
| 70 | -| `LabelTemplateListItemsHelper.cs` | `items` / `itemNames` 与 contents 使用同一套展示名逻辑 | | |
| 71 | -| `LabelTemplateAppService.cs` | 列表/详情 **优先** element 汇总 contents;保存仍写入 `Contents` 列(若已迁移) | | |
| 72 | - | |
| 73 | -### 前端 | |
| 74 | - | |
| 75 | -| 文件 | 改动 | | |
| 76 | -|------|------| | |
| 77 | -| `labelTemplate.ts` | 新增 `allocateElementDisplayName`、`resolveElementContentsLabel`、`deriveContentsLabelFromTypeAdd` | | |
| 78 | -| `LabelTemplateEditor/index.tsx` | 新控件默认 Element name = `控件名 (For Template/For Label…)` | | |
| 79 | -| `LabelTemplateEditor/index.tsx` | 保存时 `contents: buildTemplateContentsFromElements(elements)` | | |
| 80 | - | |
| 81 | ---- | |
| 82 | - | |
| 83 | -## 五、接口说明 | |
| 84 | - | |
| 85 | -### 1. 模板详情 | |
| 86 | - | |
| 87 | -| 项目 | 内容 | | |
| 88 | -|------|------| | |
| 89 | -| 方法 | `GET` | | |
| 90 | -| 路径 | `/api/app/label-template/{id}` | | |
| 91 | -| 示例 | `/api/app/label-template/tpl_c23gov_mqri4g5a` | | |
| 92 | - | |
| 93 | -**出参(节选)** | |
| 94 | - | |
| 95 | -```json | |
| 96 | -{ | |
| 97 | - "id": "tpl_c23gov_mqri4g5a", | |
| 98 | - "templateName": "Unnamed template", | |
| 99 | - "contents": "Nutrition Facts (For Label), Text (For Template)", | |
| 100 | - "elements": [ | |
| 101 | - { | |
| 102 | - "elementName": "nutritionfacts", | |
| 103 | - "typeAdd": "label_Nutrition Facts", | |
| 104 | - "type": "NUTRITIONFACTS" | |
| 105 | - }, | |
| 106 | - { | |
| 107 | - "elementName": "text", | |
| 108 | - "typeAdd": "template_Text", | |
| 109 | - "type": "TEXT_STATIC" | |
| 110 | - } | |
| 111 | - ] | |
| 112 | -} | |
| 113 | -``` | |
| 114 | - | |
| 115 | -> 旧 slug 的 `elementName` 可保留;`contents` 按 typeAdd 展示。保存后新模板会直接写入展示型 Element name。 | |
| 116 | - | |
| 117 | -### 2. 模板列表 | |
| 118 | - | |
| 119 | -| 项目 | 内容 | | |
| 120 | -|------|------| | |
| 121 | -| 方法 | `GET` | | |
| 122 | -| 路径 | `/api/app/label-template` | | |
| 123 | -| 参数 | `SkipCount=1&MaxResultCount=10` | | |
| 124 | - | |
| 125 | -**出参 `items[]`** | |
| 126 | - | |
| 127 | -| 字段 | 说明 | | |
| 128 | -|------|------| | |
| 129 | -| `contents` | Contents 列文案,与详情一致 | | |
| 130 | -| `items` | 兼容字段,与 `contents` 相同 | | |
| 131 | -| `contentsCount` | 控件数量 | | |
| 132 | - | |
| 133 | -### 3. 新建 / 编辑 | |
| 134 | - | |
| 135 | -| 方法 | 路径 | | |
| 136 | -|------|------| | |
| 137 | -| `POST` | `/api/app/label-template` | | |
| 138 | -| `PUT` | `/api/app/label-template/{id}` | | |
| 139 | - | |
| 140 | -**Body 节选** | |
| 141 | - | |
| 142 | -```json | |
| 143 | -{ | |
| 144 | - "id": "tpl_c23gov_mqri4g5a", | |
| 145 | - "name": "Unnamed template", | |
| 146 | - "contents": "Nutrition Facts (For Label), Text (For Template)", | |
| 147 | - "elements": [ | |
| 148 | - { | |
| 149 | - "id": "el_1", | |
| 150 | - "elementName": "Nutrition Facts (For Label)", | |
| 151 | - "typeAdd": "label_Nutrition Facts", | |
| 152 | - "type": "NUTRITIONFACTS", | |
| 153 | - "orderNum": 1 | |
| 154 | - }, | |
| 155 | - { | |
| 156 | - "id": "el_2", | |
| 157 | - "elementName": "Text (For Template)", | |
| 158 | - "typeAdd": "template_Text", | |
| 159 | - "type": "TEXT_STATIC", | |
| 160 | - "orderNum": 2 | |
| 161 | - } | |
| 162 | - ] | |
| 163 | -} | |
| 164 | -``` | |
| 165 | - | |
| 166 | -| 字段 | 说明 | | |
| 167 | -|------|------| | |
| 168 | -| `contents` | 可选;不传则后端按 `elements` 推导 | | |
| 169 | -| `elements[].elementName` | **主数据源**(Element name) | | |
| 170 | -| `elements[].typeAdd` | slug 兼容推导用 | | |
| 171 | - | |
| 172 | ---- | |
| 173 | - | |
| 174 | -## 六、部署与验证 | |
| 175 | - | |
| 176 | -1. 重新编译并重启 `Yi.Abp.Web` | |
| 177 | -2. (可选)执行 `scripts/fl_label_template_contents.sql` 持久化 `Contents` 列 | |
| 178 | -3. 打开 `tpl_c23gov_mqri4g5a` 保存一次 → GET 详情/列表 `contents` 应为展示名,而非 `nutritionfacts, text` | |
| 179 | -4. 修改 Element name 后再保存 → 列表 Contents **立即**更新(不再被旧 `Contents` 缓存挡住) | |
| 180 | - | |
| 181 | -**curl 示例** | |
| 182 | - | |
| 183 | -```bash | |
| 184 | -curl -s "http://localhost:5000/api/app/label-template/tpl_c23gov_mqri4g5a" \ | |
| 185 | - -H "Authorization: Bearer <TOKEN>" | |
| 186 | -``` | |
| 187 | - | |
| 188 | ---- | |
| 189 | - | |
| 190 | -## 七、与 6-25 文档关系 | |
| 191 | - | |
| 192 | -- **6-25**:引入 `contents` 字段、DB 迁移、未迁移列兼容 | |
| 193 | -- **6-26**:明确字段来源(Element name + typeAdd 推导)、修复 slug/缓存导致的展示与编辑不生效问题 |
泰额版/Food Labeling Management App UniApp/.env.development
0 → 100644
泰额版/Food Labeling Management App UniApp/.env.example
0 → 100644
泰额版/Food Labeling Management App UniApp/.env.production
0 → 100644
泰额版/Food Labeling Management App UniApp/.hbuilderx/launch.json
泰额版/Food Labeling Management App UniApp/src/App.vue
| ... | ... | @@ -3,11 +3,13 @@ import { onLaunch, onShow, onHide } from "@dcloudio/uni-app"; |
| 3 | 3 | import { initOfflineSqlite } from "./utils/sqliteSync"; |
| 4 | 4 | import { syncNowAndRefreshCaches } from "./utils/offlineSyncManager"; |
| 5 | 5 | import { isLoggedIn } from "./utils/authSession"; |
| 6 | +import { preloadAllLabelEditorFonts } from "./utils/labelEditorFonts"; | |
| 6 | 7 | |
| 7 | 8 | let networkSyncInFlight = false; |
| 8 | 9 | |
| 9 | 10 | onLaunch(() => { |
| 10 | 11 | void initOfflineSqlite(); |
| 12 | + void preloadAllLabelEditorFonts(); | |
| 11 | 13 | uni.onNetworkStatusChange((res) => { |
| 12 | 14 | if (!res.isConnected || !isLoggedIn() || networkSyncInFlight) return; |
| 13 | 15 | networkSyncInFlight = true; | ... | ... |
泰额版/Food Labeling Management App UniApp/src/components/AppDatePicker.vue
| ... | ... | @@ -64,6 +64,7 @@ const props = withDefaults( |
| 64 | 64 | |
| 65 | 65 | const emit = defineEmits<{ |
| 66 | 66 | 'update:modelValue': [value: string] |
| 67 | + cancel: [] | |
| 67 | 68 | }>() |
| 68 | 69 | |
| 69 | 70 | const dialogVisible = ref(false) |
| ... | ... | @@ -173,6 +174,7 @@ function onPickerChange(e: { detail: { value: number[] } }) { |
| 173 | 174 | |
| 174 | 175 | function cancelDialog() { |
| 175 | 176 | dialogVisible.value = false |
| 177 | + emit('cancel') | |
| 176 | 178 | } |
| 177 | 179 | |
| 178 | 180 | function confirmDialog() { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/components/AppIcon.vue
| ... | ... | @@ -10,7 +10,7 @@ import { computed } from 'vue' |
| 10 | 10 | const props = withDefaults( |
| 11 | 11 | defineProps<{ |
| 12 | 12 | name: string |
| 13 | - size?: 'sm' | 'md' | 'lg' | |
| 13 | + size?: 'xs' | 'sm' | 'md' | 'lg' | |
| 14 | 14 | color?: 'primary' | 'gray' | 'white' | 'blue' | 'orange' | 'green' | 'purple' | 'red' |
| 15 | 15 | }>(), |
| 16 | 16 | { size: 'md', color: 'gray' } |
| ... | ... | @@ -80,6 +80,7 @@ const wrapStyle = computed(() => ({})) |
| 80 | 80 | stroke-linejoin: round; |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | +.icon-xs { width: 30rpx; height: 30rpx; } | |
| 83 | 84 | .icon-sm { width: 40rpx; height: 40rpx; } |
| 84 | 85 | .icon-md { width: 48rpx; height: 48rpx; } |
| 85 | 86 | .icon-lg { width: 64rpx; height: 64rpx; } | ... | ... |
泰额版/Food Labeling Management App UniApp/src/env.d.ts
泰额版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
| ... | ... | @@ -28,7 +28,7 @@ |
| 28 | 28 | <text class="tab-text">Bluetooth</text> |
| 29 | 29 | </view> |
| 30 | 30 | <view |
| 31 | - v-if="isBuiltinModeAvailable" | |
| 31 | + v-if="isBuiltinModeAvailable && SHOW_BUILTIN_PRINTER_UI" | |
| 32 | 32 | class="tab" |
| 33 | 33 | :class="{ active: printerType === 'builtin' }" |
| 34 | 34 | @click="switchType('builtin')" |
| ... | ... | @@ -64,8 +64,35 @@ |
| 64 | 64 | </view> |
| 65 | 65 | </view> |
| 66 | 66 | |
| 67 | + <!-- Print route debug: hidden in production UI --> | |
| 68 | + <view v-if="connectedDevice && SHOW_PRINT_ROUTE_DEBUG" class="print-route-card"> | |
| 69 | + <view class="print-route-header"> | |
| 70 | + <text class="print-route-title">Print Route (Debug)</text> | |
| 71 | + <view class="btn-refresh-debug" @click="refreshPrintRouteDebug"> | |
| 72 | + <text class="btn-refresh-debug-text">Refresh</text> | |
| 73 | + </view> | |
| 74 | + </view> | |
| 75 | + <text class="debug-item">Send Channel: {{ printRouteDebug.sendChannel }}</text> | |
| 76 | + <text class="debug-item">Virtual BT: {{ printRouteDebug.virtualBt ? 'Yes' : 'No' }}</text> | |
| 77 | + <text class="debug-item">UPOS Supported: {{ printRouteDebug.uposSupported ? 'Yes' : 'No' }}</text> | |
| 78 | + <text class="debug-item">UPOS Will Try: {{ printRouteDebug.uposWillTry ? 'Yes' : 'No' }}</text> | |
| 79 | + <text class="debug-item">Native Stage: {{ printRouteDebug.nativeStage || '-' }}</text> | |
| 80 | + <text class="debug-item">Command Bytes: {{ printRouteDebug.commandBytes || 0 }}</text> | |
| 81 | + <text class="debug-item">Write Ms: {{ printRouteDebug.writeMs || 0 }}</text> | |
| 82 | + <text v-if="printRouteDebug.lastError" class="debug-item debug-error">{{ printRouteDebug.lastError }}</text> | |
| 83 | + <text v-if="lastPrintDiagAtLabel" class="debug-item debug-muted">{{ lastPrintDiagAtLabel }}</text> | |
| 84 | + <view class="print-route-actions"> | |
| 85 | + <view class="btn-view-log" @click="showLastPrintLogModal"> | |
| 86 | + <text class="btn-view-log-text">View last print log</text> | |
| 87 | + </view> | |
| 88 | + </view> | |
| 89 | + <text class="print-route-hint"> | |
| 90 | + D320FAX:Send Channel 应为 Gprinter SDK TSC (Virtual BT)。若显示 UPOS 或 printCommandBytes 且无 Write Ms,则不会出纸。 | |
| 91 | + </text> | |
| 92 | + </view> | |
| 93 | + | |
| 67 | 94 | <!-- Bluetooth: scan & list --> |
| 68 | - <template v-if="printerType === 'bluetooth'"> | |
| 95 | + <template v-if="printerType === 'bluetooth' || !SHOW_BUILTIN_PRINTER_UI"> | |
| 69 | 96 | <view class="section-header"> |
| 70 | 97 | <text class="section-title">Available Printers</text> |
| 71 | 98 | <view class="btn-scan" :class="{ scanning: isScanning }" @click="handleScan"> |
| ... | ... | @@ -73,7 +100,7 @@ |
| 73 | 100 | <text class="btn-scan-text">{{ isScanning ? 'Scanning...' : 'Scan' }}</text> |
| 74 | 101 | </view> |
| 75 | 102 | </view> |
| 76 | - <view v-if="isBuiltinModeAvailable" class="switch-to-builtin-row"> | |
| 103 | + <view v-if="isBuiltinModeAvailable && SHOW_BUILTIN_PRINTER_UI" class="switch-to-builtin-row"> | |
| 77 | 104 | <view class="btn-switch-to-builtin" @click="switchToBuiltinMode"> |
| 78 | 105 | <text class="btn-switch-to-builtin-text">Built-in Printing</text> |
| 79 | 106 | </view> |
| ... | ... | @@ -121,8 +148,8 @@ |
| 121 | 148 | </view> |
| 122 | 149 | </template> |
| 123 | 150 | |
| 124 | - <!-- Built-in: single option --> | |
| 125 | - <template v-else> | |
| 151 | + <!-- Built-in / UPOS:仅开发调试时展示 --> | |
| 152 | + <template v-else-if="SHOW_BUILTIN_PRINTER_UI"> | |
| 126 | 153 | <view class="section-header"> |
| 127 | 154 | <text class="section-title">Built-in Printer</text> |
| 128 | 155 | </view> |
| ... | ... | @@ -138,14 +165,14 @@ |
| 138 | 165 | </view> |
| 139 | 166 | </view> |
| 140 | 167 | |
| 141 | - <view class="builtin-advanced-entry"> | |
| 168 | + <view v-if="SHOW_BUILTIN_UPOS_ADVANCED" class="builtin-advanced-entry"> | |
| 142 | 169 | <view class="btn-advanced" @click="showBuiltinAdvanced = !showBuiltinAdvanced"> |
| 143 | 170 | <text class="btn-advanced-text">{{ showBuiltinAdvanced ? 'Hide advanced options' : 'Show advanced options' }}</text> |
| 144 | 171 | </view> |
| 145 | 172 | </view> |
| 146 | 173 | |
| 147 | 174 | <!-- UPOS options: builtin / serial --> |
| 148 | - <view v-if="showBuiltinAdvanced" class="upos-card"> | |
| 175 | + <view v-if="SHOW_BUILTIN_UPOS_ADVANCED && showBuiltinAdvanced" class="upos-card"> | |
| 149 | 176 | <text class="upos-title">UPOS options (for AIO devices)</text> |
| 150 | 177 | <text class="upos-desc"> |
| 151 | 178 | If printing does not feed paper, try switching to Serial and set a correct port/baudrate. |
| ... | ... | @@ -227,15 +254,12 @@ |
| 227 | 254 | <text class="tips-item">3. Place the printer within 10 m and tap Scan again</text> |
| 228 | 255 | <text class="tips-item">4. Scan list only shows: GP-D320FX-spp_A7FO and Virtual BT Printer</text> |
| 229 | 256 | <text class="tips-item">5. Pair the printer in system Bluetooth first if it does not appear after Scan</text> |
| 230 | - <text class="tips-item">6. Restart the printer or app if not visible</text> | |
| 231 | - <text class="tips-item tips-item-last"> | |
| 232 | - 7. Built-in TSC: defaults to TSPL label commands via UPOS; use Advanced → ESC/POS only if your device is receipt-only. | |
| 233 | - </text> | |
| 257 | + <text class="tips-item tips-item-last">6. Restart the printer or app if not visible</text> | |
| 234 | 258 | </view> |
| 235 | 259 | </view> |
| 236 | 260 | </view> |
| 237 | 261 | |
| 238 | - <view class="collapse-section collapse-section--debug"> | |
| 262 | + <view v-if="SHOW_PRINTER_DEBUG_UI" class="collapse-section collapse-section--debug"> | |
| 239 | 263 | <view class="collapse-header collapse-header--debug" @click="debugExpanded = !debugExpanded"> |
| 240 | 264 | <text class="collapse-title">Debug Status</text> |
| 241 | 265 | <view class="collapse-action-wrap"> |
| ... | ... | @@ -254,6 +278,10 @@ |
| 254 | 278 | <text class="debug-item">Native Backend: {{ nativeDebug.backend || '-' }}</text> |
| 255 | 279 | <text class="debug-item">Native Stage: {{ nativeDebug.stage || '-' }}</text> |
| 256 | 280 | <text class="debug-item">Native Command Bytes: {{ nativeDebug.commandBytes || 0 }}</text> |
| 281 | + <text class="debug-item">Native Write Ms: {{ nativeDebug.writeMs || 0 }}</text> | |
| 282 | + <text class="debug-item">Send Channel: {{ printRouteDebug.sendChannel }}</text> | |
| 283 | + <text class="debug-item">Virtual BT Linked: {{ printRouteDebug.virtualBt ? 'Yes' : 'No' }}</text> | |
| 284 | + <text class="debug-item">UPOS Will Try: {{ printRouteDebug.uposWillTry ? 'Yes' : 'No' }}</text> | |
| 257 | 285 | <text class="debug-item">Paired Count: {{ debugInfo.pairedCount }}</text> |
| 258 | 286 | <text class="debug-item">Virtual BT Printer: {{ debugInfo.foundVirtualPrinter ? 'Found' : 'Not Found' }}</text> |
| 259 | 287 | <text class="debug-item">Classic Scan: {{ debugInfo.lastClassicEvent }}</text> |
| ... | ... | @@ -274,6 +302,7 @@ |
| 274 | 302 | |
| 275 | 303 | <script setup lang="ts"> |
| 276 | 304 | import { ref, computed, onMounted, onUnmounted } from 'vue' |
| 305 | +import { onShow } from '@dcloudio/uni-app' | |
| 277 | 306 | import AppIcon from '../../components/AppIcon.vue' |
| 278 | 307 | import SideMenu from '../../components/SideMenu.vue' |
| 279 | 308 | import LocationPicker from '../../components/LocationPicker.vue' |
| ... | ... | @@ -287,7 +316,12 @@ import { |
| 287 | 316 | type PrinterType, |
| 288 | 317 | PrinterStorageKeys, |
| 289 | 318 | setUposOptions, |
| 319 | + getPrinterDebugSnapshot, | |
| 320 | + isVirtualBtBluetoothConnection, | |
| 321 | + restoreD320faxVirtualBtBluetoothMode, | |
| 322 | + resolvePrintSendChannelHint, | |
| 290 | 323 | setBuiltinPrinter, |
| 324 | + isAoaAioDevice, | |
| 291 | 325 | } from '../../utils/print/printerConnection' |
| 292 | 326 | import { ensureBluetoothPermissions } from '../../utils/print/bluetoothPermissions' |
| 293 | 327 | import { isAllowedBluetoothPrinterName } from '../../utils/print/bluetoothPrinterAllowlist' |
| ... | ... | @@ -309,6 +343,10 @@ import { |
| 309 | 343 | testPrintCurrentPrinter, |
| 310 | 344 | useBuiltinPrinter, |
| 311 | 345 | } from '../../utils/print/manager/printerManager' |
| 346 | +import { | |
| 347 | + getPersistedPrintRunDiagnostics, | |
| 348 | + getPersistedPrintRunDiagnosticsAt, | |
| 349 | +} from '../../utils/print/printRunDiagnostics' | |
| 312 | 350 | |
| 313 | 351 | const statusBarHeight = getStatusBarHeight() |
| 314 | 352 | const isMenuOpen = ref(false) |
| ... | ... | @@ -321,11 +359,23 @@ const btAdapterReady = ref(false) |
| 321 | 359 | const availablePrinterTypes = getAvailablePrinterTypes() |
| 322 | 360 | const availablePrinterTypesLabel = availablePrinterTypes.map(item => item === 'builtin' ? 'Built-in' : 'Bluetooth').join(' / ') |
| 323 | 361 | const deviceIdentity = getDeviceIdentity() |
| 324 | -const storedPrinterType = getPrinterType() | |
| 325 | -const preferredPrinterType = (storedPrinterType && availablePrinterTypes.includes(storedPrinterType as PrinterType)) | |
| 326 | - ? storedPrinterType as PrinterType | |
| 327 | - : availablePrinterTypes[0] | |
| 328 | -const printerType = ref<PrinterType | ''>(preferredPrinterType || 'bluetooth') | |
| 362 | +const isAoaDevice = isAoaAioDevice() | |
| 363 | +const isBluetoothModeAvailable = availablePrinterTypes.includes('bluetooth') | |
| 364 | +const isBuiltinModeAvailable = availablePrinterTypes.includes('builtin') | |
| 365 | +/** 打印机页「Print Route (Debug)」卡片;正式版对用户隐藏 */ | |
| 366 | +const SHOW_PRINT_ROUTE_DEBUG = false | |
| 367 | +/** AOA 一体机显示 Built-in 入口;普通手机隐藏 */ | |
| 368 | +const SHOW_BUILTIN_PRINTER_UI = isAoaDevice | |
| 369 | +/** UPOS 高级选项仅开发调试 */ | |
| 370 | +const SHOW_BUILTIN_UPOS_ADVANCED = false | |
| 371 | +/** Troubleshooting 下方 Debug Status;正式版对用户隐藏 */ | |
| 372 | +const SHOW_PRINTER_DEBUG_UI = false | |
| 373 | +/** AOA 默认内置打印;其它设备默认蓝牙 */ | |
| 374 | +const printerType = ref<PrinterType>( | |
| 375 | + isAoaDevice && isBuiltinModeAvailable | |
| 376 | + ? 'builtin' | |
| 377 | + : (isBluetoothModeAvailable ? 'bluetooth' : (isBuiltinModeAvailable ? 'builtin' : 'bluetooth')), | |
| 378 | +) | |
| 329 | 379 | const currentPrinter = ref(getCurrentPrinterSummary()) |
| 330 | 380 | const showBuiltinAdvanced = ref(false) |
| 331 | 381 | const debugInfo = ref({ |
| ... | ... | @@ -339,6 +389,51 @@ const debugInfo = ref({ |
| 339 | 389 | locationServiceRequired: false, |
| 340 | 390 | }) |
| 341 | 391 | const nativeDebug = ref(getNativeFastPrinterState() || {}) |
| 392 | +const printRouteDebug = ref({ | |
| 393 | + sendChannel: '-', | |
| 394 | + virtualBt: false, | |
| 395 | + uposSupported: false, | |
| 396 | + uposWillTry: false, | |
| 397 | + nativeStage: '-', | |
| 398 | + commandBytes: 0, | |
| 399 | + writeMs: 0, | |
| 400 | + lastError: '', | |
| 401 | +}) | |
| 402 | + | |
| 403 | +const lastPrintDiagAtLabel = computed(() => { | |
| 404 | + const at = getPersistedPrintRunDiagnosticsAt() | |
| 405 | + if (!at) return '' | |
| 406 | + const d = new Date(at) | |
| 407 | + return `Last print log: ${d.toLocaleString()}` | |
| 408 | +}) | |
| 409 | + | |
| 410 | +async function refreshPrintRouteDebug () { | |
| 411 | + const snap = await getPrinterDebugSnapshot({ reason: 'printer-page' }) | |
| 412 | + const native = snap.nativeFastPrinter || {} | |
| 413 | + printRouteDebug.value = { | |
| 414 | + sendChannel: snap.builtin.sendChannelHint || resolvePrintSendChannelHint(), | |
| 415 | + virtualBt: snap.builtin.virtualBtLinked || isVirtualBtBluetoothConnection(), | |
| 416 | + uposSupported: !!snap.builtin.uposSupported, | |
| 417 | + uposWillTry: !!snap.builtin.uposWillTry, | |
| 418 | + nativeStage: String(native.stage || nativeDebug.value.stage || '-'), | |
| 419 | + commandBytes: Number(native.commandBytes || nativeDebug.value.commandBytes || 0), | |
| 420 | + writeMs: Number(native.writeMs || nativeDebug.value.writeMs || 0), | |
| 421 | + lastError: String(native.lastError || nativeDebug.value.lastError || ''), | |
| 422 | + } | |
| 423 | +} | |
| 424 | + | |
| 425 | +function showLastPrintLogModal () { | |
| 426 | + const persisted = getPersistedPrintRunDiagnostics().trim() | |
| 427 | + const content = persisted | |
| 428 | + ? (persisted.length > 3600 ? persisted.slice(-3600) : persisted) | |
| 429 | + : 'No print log yet. Print a label from Preview first, then return here and tap Refresh.' | |
| 430 | + uni.showModal({ | |
| 431 | + title: 'Last Print Log', | |
| 432 | + content, | |
| 433 | + showCancel: false, | |
| 434 | + confirmText: 'OK', | |
| 435 | + }) | |
| 436 | +} | |
| 342 | 437 | |
| 343 | 438 | const uposPrefer = ref<'builtin' | 'serial'>( |
| 344 | 439 | (String(uni.getStorageSync(PrinterStorageKeys.uposPrefer) || '').toLowerCase() === 'serial' ? 'serial' : 'builtin') as |
| ... | ... | @@ -366,8 +461,6 @@ interface BtDevice { |
| 366 | 461 | |
| 367 | 462 | const devices = ref<BtDevice[]>([]) |
| 368 | 463 | const discoveredIds = new Set<string>() |
| 369 | -const isBluetoothModeAvailable = availablePrinterTypes.includes('bluetooth') | |
| 370 | -const isBuiltinModeAvailable = availablePrinterTypes.includes('builtin') | |
| 371 | 464 | |
| 372 | 465 | function refreshCurrentPrinter () { |
| 373 | 466 | currentPrinter.value = getCurrentPrinterSummary() |
| ... | ... | @@ -385,6 +478,7 @@ async function refreshNativeDebug () { |
| 385 | 478 | } catch (_) { |
| 386 | 479 | nativeDebug.value = getNativeFastPrinterState() || {} |
| 387 | 480 | } |
| 481 | + await refreshPrintRouteDebug() | |
| 388 | 482 | } |
| 389 | 483 | |
| 390 | 484 | function onBuiltinDriverSegmentTap (key: 'generic-tsc' | 'generic-esc') { |
| ... | ... | @@ -463,6 +557,7 @@ const connectedDevice = computed(() => { |
| 463 | 557 | }) |
| 464 | 558 | |
| 465 | 559 | function switchType (type: 'bluetooth' | 'builtin') { |
| 560 | + if (!SHOW_BUILTIN_PRINTER_UI && type === 'builtin') return | |
| 466 | 561 | if (!availablePrinterTypes.includes(type)) return |
| 467 | 562 | printerType.value = type |
| 468 | 563 | showBuiltinAdvanced.value = false |
| ... | ... | @@ -473,7 +568,7 @@ function switchType (type: 'bluetooth' | 'builtin') { |
| 473 | 568 | } |
| 474 | 569 | |
| 475 | 570 | function switchToBuiltinMode () { |
| 476 | - if (!isBuiltinModeAvailable) return | |
| 571 | + if (!SHOW_BUILTIN_PRINTER_UI || !isBuiltinModeAvailable) return | |
| 477 | 572 | switchType('builtin') |
| 478 | 573 | } |
| 479 | 574 | |
| ... | ... | @@ -515,6 +610,11 @@ const startBleScan = () => { |
| 515 | 610 | if (err?.errCode === 10016 || err?.code === 10016) { |
| 516 | 611 | debugInfo.value.locationServiceRequired = true |
| 517 | 612 | errorMsg.value = 'Bluetooth scan failed: Android system Location service is turned off.' |
| 613 | + } else if (devices.value.length === 0) { | |
| 614 | + errorMsg.value = 'BLE scan failed. Check Bluetooth and Location permissions.' | |
| 615 | + } | |
| 616 | + if (devices.value.length > 0) { | |
| 617 | + isScanning.value = false | |
| 518 | 618 | } |
| 519 | 619 | }, |
| 520 | 620 | }) |
| ... | ... | @@ -612,6 +712,10 @@ function startClassicScan () { |
| 612 | 712 | }, |
| 613 | 713 | () => { |
| 614 | 714 | debugInfo.value.lastClassicEvent = 'scan finished' |
| 715 | + if (devices.value.length > 0) { | |
| 716 | + isScanning.value = false | |
| 717 | + stopDiscovery() | |
| 718 | + } | |
| 615 | 719 | }, |
| 616 | 720 | ) |
| 617 | 721 | isScanning.value = true |
| ... | ... | @@ -653,7 +757,6 @@ const handleScan = async () => { |
| 653 | 757 | if (hasPreferredClassicDeviceInList()) { |
| 654 | 758 | isScanning.value = false |
| 655 | 759 | debugInfo.value.lastBleEvent = 'skipped (paired classic device found)' |
| 656 | - uni.showToast({ title: 'Using paired classic Bluetooth devices', icon: 'none' }) | |
| 657 | 760 | return |
| 658 | 761 | } |
| 659 | 762 | |
| ... | ... | @@ -671,7 +774,8 @@ const handleScan = async () => { |
| 671 | 774 | if (isScanning.value) stopDiscovery() |
| 672 | 775 | }, 20000) |
| 673 | 776 | } catch (_) { |
| 674 | - if (!isScanning.value && devices.value.length === 0) { | |
| 777 | + isScanning.value = false | |
| 778 | + if (devices.value.length === 0) { | |
| 675 | 779 | errorMsg.value = 'Bluetooth scan failed. Check if Bluetooth is enabled.' |
| 676 | 780 | } |
| 677 | 781 | } |
| ... | ... | @@ -699,6 +803,7 @@ const handleConnect = async (dev: BtDevice) => { |
| 699 | 803 | uni.showToast({ title: 'Connected!', icon: 'success' }) |
| 700 | 804 | } catch (e: any) { |
| 701 | 805 | await refreshNativeDebug() |
| 806 | + refreshCurrentPrinter() | |
| 702 | 807 | errorMsg.value = (e && e.message) ? e.message : 'Connection failed' |
| 703 | 808 | connectingId.value = '' |
| 704 | 809 | } |
| ... | ... | @@ -832,22 +937,74 @@ const handleDisconnect = async () => { |
| 832 | 937 | uni.showToast({ title: 'Disconnected', icon: 'none' }) |
| 833 | 938 | } |
| 834 | 939 | |
| 940 | +function ensureAoaBuiltinPrinterReady () { | |
| 941 | + if (!isAoaDevice || !isBuiltinModeAvailable) return | |
| 942 | + printerType.value = 'builtin' | |
| 943 | + if (getPrinterType() !== 'builtin') { | |
| 944 | + useBuiltinPrinter(builtinDriverKey.value) | |
| 945 | + logBuiltinTscCapability('aoa_auto_builtin', { | |
| 946 | + screen: 'bluetooth_builtin', | |
| 947 | + driverKey: builtinDriverKey.value, | |
| 948 | + model: deviceIdentity.model || '', | |
| 949 | + product: deviceIdentity.product || '', | |
| 950 | + }) | |
| 951 | + refreshCurrentPrinter() | |
| 952 | + } | |
| 953 | +} | |
| 954 | + | |
| 835 | 955 | onMounted(() => { |
| 836 | 956 | debugInfo.value.classicModuleReady = !!classicBluetooth |
| 837 | 957 | refreshNativeDebug() |
| 838 | - uni.onBluetoothDeviceFound(onDeviceFound) | |
| 839 | - uni.onBluetoothAdapterStateChange((res: any) => { | |
| 840 | - if (!res.available) { | |
| 841 | - btAdapterReady.value = false | |
| 958 | + ensureAoaBuiltinPrinterReady() | |
| 959 | + if (!isAoaDevice) { | |
| 960 | + uni.onBluetoothDeviceFound(onDeviceFound) | |
| 961 | + uni.onBluetoothAdapterStateChange((res: any) => { | |
| 962 | + if (!res.available) { | |
| 963 | + btAdapterReady.value = false | |
| 964 | + isScanning.value = false | |
| 965 | + errorMsg.value = 'Bluetooth has been turned off.' | |
| 966 | + } else { | |
| 967 | + btAdapterReady.value = true | |
| 968 | + errorMsg.value = '' | |
| 969 | + } | |
| 970 | + }) | |
| 971 | + } | |
| 972 | + refreshCurrentPrinter() | |
| 973 | +}) | |
| 974 | + | |
| 975 | +async function refreshBluetoothDeviceList () { | |
| 976 | + if (printerType.value !== 'bluetooth') return | |
| 977 | + try { | |
| 978 | + const permissionResult = await ensureBluetoothPermissions({ scan: true, connect: true }) | |
| 979 | + if (!permissionResult.ok) { | |
| 980 | + errorMsg.value = permissionResult.message || 'Bluetooth permission denied.' | |
| 981 | + return | |
| 982 | + } | |
| 983 | + await initBluetooth() | |
| 984 | + addPairedDevices() | |
| 985 | + if (devices.value.length > 0) { | |
| 842 | 986 | isScanning.value = false |
| 843 | - errorMsg.value = 'Bluetooth has been turned off.' | |
| 844 | - } else { | |
| 845 | - btAdapterReady.value = true | |
| 846 | - errorMsg.value = '' | |
| 987 | + return | |
| 847 | 988 | } |
| 848 | - }) | |
| 849 | - printerType.value = preferredPrinterType || 'bluetooth' | |
| 989 | + if (!isScanning.value) { | |
| 990 | + await handleScan() | |
| 991 | + } | |
| 992 | + } catch (_) { | |
| 993 | + /* ignore; user can tap Scan manually */ | |
| 994 | + } | |
| 995 | +} | |
| 996 | + | |
| 997 | +onShow(() => { | |
| 998 | + restoreD320faxVirtualBtBluetoothMode() | |
| 999 | + ensureAoaBuiltinPrinterReady() | |
| 850 | 1000 | refreshCurrentPrinter() |
| 1001 | + if (isVirtualBtBluetoothConnection()) { | |
| 1002 | + debugExpanded.value = true | |
| 1003 | + } | |
| 1004 | + void refreshNativeDebug() | |
| 1005 | + if (!isAoaDevice) { | |
| 1006 | + void refreshBluetoothDeviceList() | |
| 1007 | + } | |
| 851 | 1008 | }) |
| 852 | 1009 | |
| 853 | 1010 | onUnmounted(() => { |
| ... | ... | @@ -1362,6 +1519,71 @@ onUnmounted(() => { |
| 1362 | 1519 | font-weight: 600; |
| 1363 | 1520 | } |
| 1364 | 1521 | |
| 1522 | +.print-route-card { | |
| 1523 | + margin-top: 24rpx; | |
| 1524 | + padding: 28rpx 32rpx; | |
| 1525 | + border-radius: 16rpx; | |
| 1526 | + border: 2rpx solid rgba(31, 58, 138, 0.35); | |
| 1527 | + background: #eef2fb; | |
| 1528 | + box-sizing: border-box; | |
| 1529 | +} | |
| 1530 | + | |
| 1531 | +.print-route-header { | |
| 1532 | + display: flex; | |
| 1533 | + flex-direction: row; | |
| 1534 | + align-items: center; | |
| 1535 | + justify-content: space-between; | |
| 1536 | + margin-bottom: 16rpx; | |
| 1537 | +} | |
| 1538 | + | |
| 1539 | +.print-route-title { | |
| 1540 | + font-size: 30rpx; | |
| 1541 | + font-weight: 700; | |
| 1542 | + color: var(--theme-primary, #1f3a8a); | |
| 1543 | +} | |
| 1544 | + | |
| 1545 | +.btn-refresh-debug { | |
| 1546 | + padding: 8rpx 20rpx; | |
| 1547 | + border-radius: 999rpx; | |
| 1548 | + background: var(--theme-primary, #1f3a8a); | |
| 1549 | +} | |
| 1550 | + | |
| 1551 | +.btn-refresh-debug-text { | |
| 1552 | + font-size: 24rpx; | |
| 1553 | + color: #fff; | |
| 1554 | + font-weight: 600; | |
| 1555 | +} | |
| 1556 | + | |
| 1557 | +.print-route-actions { | |
| 1558 | + margin-top: 16rpx; | |
| 1559 | +} | |
| 1560 | + | |
| 1561 | +.btn-view-log { | |
| 1562 | + display: inline-flex; | |
| 1563 | + padding: 12rpx 24rpx; | |
| 1564 | + border-radius: 12rpx; | |
| 1565 | + border: 1rpx solid var(--theme-primary, #1f3a8a); | |
| 1566 | + background: #fff; | |
| 1567 | +} | |
| 1568 | + | |
| 1569 | +.btn-view-log-text { | |
| 1570 | + font-size: 26rpx; | |
| 1571 | + color: var(--theme-primary, #1f3a8a); | |
| 1572 | + font-weight: 600; | |
| 1573 | +} | |
| 1574 | + | |
| 1575 | +.print-route-hint { | |
| 1576 | + display: block; | |
| 1577 | + margin-top: 16rpx; | |
| 1578 | + font-size: 22rpx; | |
| 1579 | + line-height: 1.5; | |
| 1580 | + color: #475569; | |
| 1581 | +} | |
| 1582 | + | |
| 1583 | +.debug-muted { | |
| 1584 | + color: #64748b; | |
| 1585 | +} | |
| 1586 | + | |
| 1365 | 1587 | .device-tag { |
| 1366 | 1588 | display: inline-block; |
| 1367 | 1589 | font-size: 20rpx; | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/labels/labels.vue
| ... | ... | @@ -40,29 +40,47 @@ |
| 40 | 40 | @click="selectedCategoryId = cat.id" |
| 41 | 41 | > |
| 42 | 42 | <view v-if="selectedCategoryId === cat.id" class="active-bar" /> |
| 43 | - <view | |
| 44 | - class="cat-icon" | |
| 45 | - :class="labelCategoryVisual(cat).mode === 'image' ? 'cat-icon--photo' : 'cat-icon--fallback'" | |
| 46 | - :style="labelCategoryIconBoxStyle(cat)" | |
| 47 | - > | |
| 48 | - <image | |
| 49 | - v-if="labelCategoryVisual(cat).mode === 'image'" | |
| 50 | - :src="resolveMediaUrlForApp(labelCategoryVisual(cat).imageUrl) || ''" | |
| 51 | - class="cat-photo" | |
| 52 | - mode="aspectFill" | |
| 53 | - /> | |
| 54 | - <text | |
| 55 | - v-else-if="labelCategoryVisual(cat).mode === 'colorText'" | |
| 56 | - class="cat-icon-text cat-icon-text--on-color" | |
| 57 | - :style="{ color: labelCategoryVisual(cat).textColor || '#ffffff' }" | |
| 43 | + <view class="cat-card-frame"> | |
| 44 | + <view class="cat-card-inner"> | |
| 45 | + <view | |
| 46 | + class="cat-visual" | |
| 47 | + :class="[ | |
| 48 | + labelCategoryVisual(cat).mode === 'image' ? 'cat-visual--photo' : 'cat-visual--fallback', | |
| 49 | + ]" | |
| 50 | + :style="labelCategoryVisualZoneStyle(cat)" | |
| 58 | 51 | > |
| 59 | - {{ labelCategoryVisual(cat).text }} | |
| 60 | - </text> | |
| 61 | - <text v-else-if="labelCategoryVisual(cat).mode === 'text'" class="cat-icon-text"> | |
| 62 | - {{ labelCategoryVisual(cat).text }} | |
| 63 | - </text> | |
| 52 | + <image | |
| 53 | + v-if="labelCategoryVisual(cat).mode === 'image'" | |
| 54 | + :src="resolveMediaUrlForApp(labelCategoryVisual(cat).imageUrl) || ''" | |
| 55 | + class="cat-photo-fill" | |
| 56 | + mode="aspectFill" | |
| 57 | + /> | |
| 58 | + <view | |
| 59 | + v-else-if="labelCategoryVisual(cat).mode === 'colorText'" | |
| 60 | + class="cat-visual-text-inner" | |
| 61 | + > | |
| 62 | + <text | |
| 63 | + class="cat-visual-text cat-visual-text--on-color" | |
| 64 | + :style="{ color: labelCategoryVisual(cat).textColor || '#ffffff' }" | |
| 65 | + > | |
| 66 | + {{ labelCategoryVisual(cat).text }} | |
| 67 | + </text> | |
| 68 | + </view> | |
| 69 | + <view v-else-if="labelCategoryVisual(cat).mode === 'color'" class="cat-visual-color-fill" /> | |
| 70 | + <view v-else-if="labelCategoryVisual(cat).mode === 'text'" class="cat-visual-text-inner"> | |
| 71 | + <text class="cat-visual-text">{{ labelCategoryVisual(cat).text }}</text> | |
| 72 | + </view> | |
| 73 | + <view v-else class="cat-visual-fallback" :class="colorClassForName(cat.categoryName)"> | |
| 74 | + <text class="cat-visual-text cat-visual-text--on-color"> | |
| 75 | + {{ (cat.categoryName || '').trim().slice(0, 12) }} | |
| 76 | + </text> | |
| 77 | + </view> | |
| 78 | + </view> | |
| 79 | + <view class="cat-name-band"> | |
| 80 | + <text class="cat-name">{{ cat.categoryName }}</text> | |
| 81 | + </view> | |
| 82 | + </view> | |
| 64 | 83 | </view> |
| 65 | - <text class="cat-name">{{ cat.categoryName }}</text> | |
| 66 | 84 | </view> |
| 67 | 85 | </view> |
| 68 | 86 | |
| ... | ... | @@ -113,11 +131,12 @@ |
| 113 | 131 | <view class="cat-header-left" :style="catHeaderLeftStyle"> |
| 114 | 132 | <view |
| 115 | 133 | class="cat-header-thumb" |
| 116 | - :class=" | |
| 134 | + :class="[ | |
| 117 | 135 | productCategoryVisual(pCat).mode === 'image' |
| 118 | 136 | ? 'cat-header-thumb--photo' |
| 119 | - : 'cat-header-thumb--fallback' | |
| 120 | - " | |
| 137 | + : 'cat-header-thumb--fallback', | |
| 138 | + 'cat-header-thumb--fixed', | |
| 139 | + ]" | |
| 121 | 140 | :style="catHeaderThumbStyle(pCat)" |
| 122 | 141 | > |
| 123 | 142 | <image |
| ... | ... | @@ -126,24 +145,30 @@ |
| 126 | 145 | class="cat-photo" |
| 127 | 146 | mode="aspectFill" |
| 128 | 147 | /> |
| 129 | - <text | |
| 148 | + <view | |
| 130 | 149 | v-else-if="productCategoryVisual(pCat).mode === 'colorText'" |
| 131 | - class="cat-icon-text cat-icon-text--on-color" | |
| 132 | - :style="{ color: productCategoryVisual(pCat).textColor || '#ffffff' }" | |
| 150 | + class="cat-header-text-inner" | |
| 133 | 151 | > |
| 134 | - {{ productCategoryVisual(pCat).text }} | |
| 135 | - </text> | |
| 152 | + <text | |
| 153 | + class="cat-icon-text cat-icon-text--on-color cat-icon-text--header cat-icon-text--wrap" | |
| 154 | + :style="{ color: productCategoryVisual(pCat).textColor || '#ffffff' }" | |
| 155 | + > | |
| 156 | + {{ categoryHeaderThumbText(pCat) }} | |
| 157 | + </text> | |
| 158 | + </view> | |
| 136 | 159 | <view |
| 137 | 160 | v-else-if="productCategoryVisual(pCat).mode === 'color'" |
| 138 | 161 | class="cat-header-color-fill" |
| 139 | 162 | :style="{ backgroundColor: productCategoryVisual(pCat).bg }" |
| 140 | 163 | /> |
| 141 | - <text | |
| 164 | + <view | |
| 142 | 165 | v-else-if="productCategoryVisual(pCat).mode === 'text'" |
| 143 | - class="cat-icon-text" | |
| 166 | + class="cat-header-text-inner" | |
| 144 | 167 | > |
| 145 | - {{ productCategoryVisual(pCat).text }} | |
| 146 | - </text> | |
| 168 | + <text class="cat-icon-text cat-icon-text--header cat-icon-text--wrap"> | |
| 169 | + {{ categoryHeaderThumbText(pCat) }} | |
| 170 | + </text> | |
| 171 | + </view> | |
| 147 | 172 | <view v-else class="cat-header-fallback" :class="colorClassForName(pCat.name)"> |
| 148 | 173 | <AppIcon name="food" size="sm" color="white" /> |
| 149 | 174 | </view> |
| ... | ... | @@ -178,57 +203,70 @@ |
| 178 | 203 | class="food-card" |
| 179 | 204 | @click="handleProductClick(product, pCat.name)" |
| 180 | 205 | > |
| 181 | - <view | |
| 182 | - class="food-img-wrap" | |
| 183 | - :class=" | |
| 184 | - productVisual(product, pCat).mode === 'image' | |
| 185 | - ? 'food-img-wrap--photo' | |
| 186 | - : 'food-img-wrap--fallback' | |
| 187 | - " | |
| 188 | - :style="productThumbWrapStyle(product, pCat)" | |
| 189 | - > | |
| 190 | - <image | |
| 191 | - v-if="productVisual(product, pCat).mode === 'image'" | |
| 192 | - :src="resolveMediaUrlForApp(productVisual(product, pCat).imageUrl) || ''" | |
| 193 | - class="food-img" | |
| 194 | - mode="aspectFill" | |
| 195 | - /> | |
| 196 | - <text | |
| 197 | - v-else-if="productVisual(product, pCat).mode === 'colorText'" | |
| 198 | - class="food-thumb-text food-thumb-text--on-color" | |
| 199 | - :style="{ color: productVisual(product, pCat).textColor || '#ffffff' }" | |
| 200 | - > | |
| 201 | - {{ productVisual(product, pCat).text }} | |
| 202 | - </text> | |
| 203 | - <text | |
| 204 | - v-else-if="productVisual(product, pCat).mode === 'text'" | |
| 205 | - class="food-thumb-text" | |
| 206 | - > | |
| 207 | - {{ productVisual(product, pCat).text }} | |
| 208 | - </text> | |
| 206 | + <view class="food-card-frame"> | |
| 207 | + <view class="food-card-inner"> | |
| 209 | 208 | <view |
| 210 | - v-else-if="productVisual(product, pCat).mode === 'color'" | |
| 211 | - class="food-thumb-color-fill" | |
| 212 | - :style="{ backgroundColor: productVisual(product, pCat).bg }" | |
| 213 | - /> | |
| 214 | - <view | |
| 215 | - v-else | |
| 216 | - class="food-thumb-fallback" | |
| 217 | - :class="colorClassForName(product.productName)" | |
| 209 | + class="food-visual" | |
| 210 | + :class=" | |
| 211 | + productVisual(product, pCat).mode === 'image' | |
| 212 | + ? 'food-visual--photo' | |
| 213 | + : 'food-visual--fallback' | |
| 214 | + " | |
| 215 | + :style="productVisualZoneStyle(product, pCat)" | |
| 218 | 216 | > |
| 219 | - <text class="food-thumb-text food-thumb-text--on-color"> | |
| 220 | - {{ productThumbFallbackText(product) }} | |
| 221 | - </text> | |
| 217 | + <image | |
| 218 | + v-if="productVisual(product, pCat).mode === 'image'" | |
| 219 | + :src="resolveMediaUrlForApp(productVisual(product, pCat).imageUrl) || ''" | |
| 220 | + class="food-img-fill" | |
| 221 | + mode="aspectFill" | |
| 222 | + /> | |
| 223 | + <view | |
| 224 | + v-else-if="productVisual(product, pCat).mode === 'colorText'" | |
| 225 | + class="food-visual-text-wrap" | |
| 226 | + > | |
| 227 | + <text | |
| 228 | + class="food-visual-text food-visual-text--on-color" | |
| 229 | + :style="{ color: productVisual(product, pCat).textColor || '#ffffff' }" | |
| 230 | + > | |
| 231 | + {{ productThumbDisplayText(product, pCat) }} | |
| 232 | + </text> | |
| 233 | + </view> | |
| 234 | + <view | |
| 235 | + v-else-if="productVisual(product, pCat).mode === 'text'" | |
| 236 | + class="food-visual-text-wrap" | |
| 237 | + > | |
| 238 | + <text class="food-visual-text"> | |
| 239 | + {{ productThumbDisplayText(product, pCat) }} | |
| 240 | + </text> | |
| 241 | + </view> | |
| 242 | + <view | |
| 243 | + v-else-if="productVisual(product, pCat).mode === 'color'" | |
| 244 | + class="food-visual-color-fill" | |
| 245 | + /> | |
| 246 | + <view | |
| 247 | + v-else | |
| 248 | + class="food-visual-fallback" | |
| 249 | + :class="colorClassForName(product.productName)" | |
| 250 | + > | |
| 251 | + <text class="food-visual-text food-visual-text--on-color"> | |
| 252 | + {{ productThumbFallbackText(product) }} | |
| 253 | + </text> | |
| 254 | + </view> | |
| 222 | 255 | </view> |
| 223 | - <view class="size-badge"> | |
| 224 | - <text class="size-badge-text">{{ primaryLabelSizeText(product) }}</text> | |
| 256 | + <view class="food-name-band"> | |
| 257 | + <text class="food-name">{{ product.productName }}</text> | |
| 258 | + </view> | |
| 259 | + <view class="food-meta-band"> | |
| 260 | + <text class="food-meta-size">{{ primaryLabelSizeText(product) }}</text> | |
| 261 | + <text | |
| 262 | + v-if="effectiveLabelTypeCount(product) > 0" | |
| 263 | + class="food-meta-types" | |
| 264 | + > | |
| 265 | + {{ effectiveLabelTypeCount(product) }} Types | |
| 266 | + </text> | |
| 225 | 267 | </view> |
| 226 | - <view v-if="effectiveLabelTypeCount(product) > 0" class="type-badge"> | |
| 227 | - <text class="type-badge-text">{{ effectiveLabelTypeCount(product) }} Types</text> | |
| 228 | 268 | </view> |
| 229 | 269 | </view> |
| 230 | - <text class="food-name">{{ product.productName }}</text> | |
| 231 | - <text class="food-desc">{{ product.subtitle || '—' }}</text> | |
| 232 | 270 | </view> |
| 233 | 271 | </view> |
| 234 | 272 | </view> |
| ... | ... | @@ -301,6 +339,18 @@ const showSubTypeModal = ref(false) |
| 301 | 339 | const selectedProduct = ref<UsAppLabelingProductNodeDto | null>(null) |
| 302 | 340 | const currentLabelTypes = ref<UsAppLabelTypeNodeDto[]>([]) |
| 303 | 341 | |
| 342 | +/** 按屏宽比例缩放列表区图标/间距(基线 375px 逻辑宽) */ | |
| 343 | +const layoutScale = ref(1) | |
| 344 | + | |
| 345 | +function refreshLayoutScale() { | |
| 346 | + const w = Number(uni.getSystemInfoSync().windowWidth || 375) | |
| 347 | + layoutScale.value = Math.min(1.12, Math.max(0.88, w / 375)) | |
| 348 | +} | |
| 349 | + | |
| 350 | +function scaleRpx(base: number): number { | |
| 351 | + return Math.round(base * layoutScale.value) | |
| 352 | +} | |
| 353 | + | |
| 304 | 354 | const labelTree = ref<UsAppLabelCategoryTreeNodeDto[]>([]) |
| 305 | 355 | const listLoading = ref(false) |
| 306 | 356 | const listError = ref('') |
| ... | ... | @@ -318,6 +368,7 @@ watch(searchTerm, () => { |
| 318 | 368 | }) |
| 319 | 369 | |
| 320 | 370 | onShow(() => { |
| 371 | + refreshLayoutScale() | |
| 321 | 372 | locationId.value = getCurrentStoreId() |
| 322 | 373 | const summary = getCurrentPrinterSummary() |
| 323 | 374 | btConnected.value = summary.type === 'bluetooth' || summary.type === 'builtin' |
| ... | ... | @@ -412,22 +463,23 @@ function labelCategoryVisual(cat: UsAppLabelCategoryTreeNodeDto): CategoryVisual |
| 412 | 463 | }) |
| 413 | 464 | } |
| 414 | 465 | |
| 415 | -function labelCategoryIconBoxStyle(cat: UsAppLabelCategoryTreeNodeDto): Record<string, string> { | |
| 466 | +function labelCategoryVisualZoneStyle(cat: UsAppLabelCategoryTreeNodeDto): Record<string, string> { | |
| 416 | 467 | const v = labelCategoryVisual(cat) |
| 417 | - if (v.mode === 'image') { | |
| 418 | - return categoryButtonDisplayWidthStyle() | |
| 419 | - } | |
| 420 | - if (v.mode === 'color') { | |
| 421 | - return { | |
| 422 | - ...categoryButtonColorOnlyBoxStyle(), | |
| 423 | - backgroundColor: v.bg, | |
| 424 | - } | |
| 468 | + if (v.mode === 'color' || v.mode === 'colorText') { | |
| 469 | + return { backgroundColor: v.bg } | |
| 425 | 470 | } |
| 426 | - const style = categoryButtonDisplayTextShellStyle() | |
| 427 | - if (v.mode === 'colorText') { | |
| 428 | - style.backgroundColor = v.bg | |
| 471 | + return {} | |
| 472 | +} | |
| 473 | + | |
| 474 | +function productVisualZoneStyle( | |
| 475 | + p: UsAppLabelingProductNodeDto, | |
| 476 | + pCat?: UsAppProductCategoryNodeDto, | |
| 477 | +): Record<string, string> { | |
| 478 | + const v = productVisual(p, pCat) | |
| 479 | + if (v.mode === 'color' || v.mode === 'colorText') { | |
| 480 | + return { backgroundColor: v.bg } | |
| 429 | 481 | } |
| 430 | - return style | |
| 482 | + return {} | |
| 431 | 483 | } |
| 432 | 484 | |
| 433 | 485 | function productCategoryVisual(p: UsAppProductCategoryNodeDto): CategoryVisualRender { |
| ... | ... | @@ -462,12 +514,12 @@ const catHeaderLeftStyle: Record<string, string> = { |
| 462 | 514 | minWidth: '0', |
| 463 | 515 | } |
| 464 | 516 | |
| 465 | -const catHeaderInfoStyle: Record<string, string> = { | |
| 517 | +const catHeaderInfoStyle = computed((): Record<string, string> => ({ | |
| 466 | 518 | flex: '1', |
| 467 | 519 | minWidth: '0', |
| 468 | - marginLeft: '0', | |
| 520 | + marginLeft: `${scaleRpx(20)}rpx`, | |
| 469 | 521 | paddingLeft: '0', |
| 470 | -} | |
| 522 | +})) | |
| 471 | 523 | |
| 472 | 524 | const catHeaderChevronStyle: Record<string, string> = { |
| 473 | 525 | flexShrink: '0', |
| ... | ... | @@ -489,9 +541,11 @@ const catFoodsStyle: Record<string, string> = { |
| 489 | 541 | |
| 490 | 542 | function catHeaderThumbStyle(p: UsAppProductCategoryNodeDto): Record<string, string> { |
| 491 | 543 | const v = productCategoryVisual(p) |
| 544 | + const scale = layoutScale.value | |
| 545 | + const gap = scaleRpx(18) | |
| 492 | 546 | if (v.mode === 'color') { |
| 493 | 547 | return { |
| 494 | - marginRight: '16rpx', | |
| 548 | + marginRight: `${gap}rpx`, | |
| 495 | 549 | marginBottom: '0', |
| 496 | 550 | marginTop: '0', |
| 497 | 551 | marginLeft: '0', |
| ... | ... | @@ -501,11 +555,11 @@ function catHeaderThumbStyle(p: UsAppProductCategoryNodeDto): Record<string, str |
| 501 | 555 | justifyContent: 'center', |
| 502 | 556 | boxSizing: 'border-box', |
| 503 | 557 | backgroundColor: v.bg, |
| 504 | - ...categoryButtonColorOnlyBoxStyle(), | |
| 558 | + ...categoryButtonColorOnlyBoxStyle(scale), | |
| 505 | 559 | } |
| 506 | 560 | } |
| 507 | 561 | const style: Record<string, string> = { |
| 508 | - marginRight: '16rpx', | |
| 562 | + marginRight: `${gap}rpx`, | |
| 509 | 563 | marginBottom: '0', |
| 510 | 564 | marginTop: '0', |
| 511 | 565 | marginLeft: '0', |
| ... | ... | @@ -515,17 +569,44 @@ function catHeaderThumbStyle(p: UsAppProductCategoryNodeDto): Record<string, str |
| 515 | 569 | justifyContent: 'center', |
| 516 | 570 | boxSizing: 'border-box', |
| 517 | 571 | backgroundColor: '#f3f4f6', |
| 518 | - ...categoryButtonDisplayTextShellStyle(), | |
| 572 | + ...categoryButtonDisplayTextShellStyle(scale), | |
| 519 | 573 | } |
| 520 | 574 | if (v.mode === 'image') { |
| 521 | - Object.assign(style, categoryButtonDisplayWidthStyle(), { borderRadius: '12rpx' }) | |
| 575 | + Object.assign(style, categoryButtonDisplayWidthStyle(scale), { | |
| 576 | + borderRadius: `${scaleRpx(12)}rpx`, | |
| 577 | + }) | |
| 522 | 578 | } |
| 523 | 579 | if (v.mode === 'colorText') { |
| 524 | 580 | style.backgroundColor = v.bg |
| 581 | + style.alignSelf = 'flex-start' | |
| 582 | + } | |
| 583 | + if (v.mode === 'text') { | |
| 584 | + style.alignSelf = 'flex-start' | |
| 525 | 585 | } |
| 526 | 586 | return style |
| 527 | 587 | } |
| 528 | 588 | |
| 589 | +/** 分类标题行图标内文案:Display Text 在框内换行展示 */ | |
| 590 | +function categoryHeaderThumbText(p: UsAppProductCategoryNodeDto): string { | |
| 591 | + const v = productCategoryVisual(p) | |
| 592 | + const raw = | |
| 593 | + v.mode === 'colorText' || v.mode === 'text' | |
| 594 | + ? (v.text || p.name || '').trim() | |
| 595 | + : (p.name || '').trim() | |
| 596 | + return raw | |
| 597 | +} | |
| 598 | + | |
| 599 | +function productThumbDisplayText( | |
| 600 | + p: UsAppLabelingProductNodeDto, | |
| 601 | + pCat?: UsAppProductCategoryNodeDto, | |
| 602 | +): string { | |
| 603 | + const v = productVisual(p, pCat) | |
| 604 | + if (v.mode !== 'text' && v.mode !== 'colorText') return '' | |
| 605 | + const t = (v.text ?? '').trim() | |
| 606 | + const name = (p.productName ?? '').trim() | |
| 607 | + return t || name || '?' | |
| 608 | +} | |
| 609 | + | |
| 529 | 610 | function productCategoryRowKey(p: UsAppProductCategoryNodeDto, index: number): string { |
| 530 | 611 | const id = p.categoryId != null ? String(p.categoryId).trim() : '' |
| 531 | 612 | if (id) return `id:${id}` |
| ... | ... | @@ -571,17 +652,6 @@ function productVisual( |
| 571 | 652 | return { mode: 'none' } |
| 572 | 653 | } |
| 573 | 654 | |
| 574 | -function productThumbWrapStyle( | |
| 575 | - p: UsAppLabelingProductNodeDto, | |
| 576 | - pCat?: UsAppProductCategoryNodeDto, | |
| 577 | -): Record<string, string> { | |
| 578 | - const v = productVisual(p, pCat) | |
| 579 | - if (v.mode === 'colorText' || v.mode === 'color') { | |
| 580 | - return { backgroundColor: v.bg } | |
| 581 | - } | |
| 582 | - return {} | |
| 583 | -} | |
| 584 | - | |
| 585 | 655 | function productThumbFallbackText(p: UsAppLabelingProductNodeDto): string { |
| 586 | 656 | const name = (p.productName ?? '').trim() |
| 587 | 657 | if (!name) return '?' |
| ... | ... | @@ -807,7 +877,7 @@ const goBluetoothPage = () => { |
| 807 | 877 | display: flex; |
| 808 | 878 | flex-direction: column; |
| 809 | 879 | align-items: center; |
| 810 | - padding: 20rpx 8rpx; | |
| 880 | + padding: 12rpx 10rpx; | |
| 811 | 881 | position: relative; |
| 812 | 882 | } |
| 813 | 883 | |
| ... | ... | @@ -825,38 +895,73 @@ const goBluetoothPage = () => { |
| 825 | 895 | border-radius: 0 6rpx 6rpx 0; |
| 826 | 896 | } |
| 827 | 897 | |
| 828 | -.cat-icon { | |
| 898 | +/* Label Category:外框 3:2,上 70% 视觉区 + 下 30% 名称 */ | |
| 899 | +.cat-card-frame { | |
| 900 | + width: 100%; | |
| 901 | + max-width: 200rpx; | |
| 902 | + position: relative; | |
| 903 | + height: 0; | |
| 904 | + padding-top: 66.6667%; | |
| 905 | + border-radius: 12rpx; | |
| 906 | + overflow: hidden; | |
| 907 | + background: #ffffff; | |
| 908 | + box-shadow: 0 2rpx 6rpx rgba(0, 0, 0, 0.06); | |
| 909 | + box-sizing: border-box; | |
| 910 | +} | |
| 911 | + | |
| 912 | +.cat-card-inner { | |
| 913 | + position: absolute; | |
| 914 | + left: 0; | |
| 915 | + top: 0; | |
| 916 | + right: 0; | |
| 917 | + bottom: 0; | |
| 918 | + display: flex; | |
| 919 | + flex-direction: column; | |
| 920 | +} | |
| 921 | + | |
| 922 | +.cat-visual { | |
| 923 | + flex: 7 1 0; | |
| 924 | + min-height: 0; | |
| 925 | + width: 100%; | |
| 829 | 926 | display: flex; |
| 830 | 927 | align-items: center; |
| 831 | 928 | justify-content: center; |
| 832 | - margin-bottom: 8rpx; | |
| 833 | - background: transparent; | |
| 834 | - box-sizing: border-box; | |
| 835 | 929 | overflow: hidden; |
| 930 | + background: #f3f4f6; | |
| 931 | + box-sizing: border-box; | |
| 836 | 932 | } |
| 837 | 933 | |
| 838 | -.cat-icon--photo { | |
| 839 | - overflow: hidden; | |
| 840 | - border-radius: 14rpx; | |
| 934 | +.cat-visual--photo, | |
| 935 | +.cat-visual--fallback { | |
| 936 | + position: relative; | |
| 841 | 937 | } |
| 842 | 938 | |
| 843 | -.cat-icon--fallback { | |
| 844 | - overflow: hidden; | |
| 845 | - border-radius: 14rpx; | |
| 939 | +.cat-photo-fill { | |
| 940 | + width: 100%; | |
| 941 | + height: 100%; | |
| 942 | + display: block; | |
| 846 | 943 | } |
| 847 | 944 | |
| 848 | -.cat-photo { | |
| 945 | +.cat-visual-color-fill { | |
| 849 | 946 | width: 100%; |
| 850 | - height: auto; | |
| 851 | - border-radius: 14rpx; | |
| 852 | - display: block; | |
| 853 | - vertical-align: top; | |
| 947 | + height: 100%; | |
| 854 | 948 | } |
| 855 | 949 | |
| 856 | -.cat-icon-text { | |
| 857 | - max-width: 100%; | |
| 858 | - padding: 4rpx 6rpx; | |
| 859 | - font-size: 20rpx; | |
| 950 | +.cat-visual-text-inner { | |
| 951 | + width: 100%; | |
| 952 | + height: 100%; | |
| 953 | + display: flex; | |
| 954 | + align-items: center; | |
| 955 | + justify-content: center; | |
| 956 | + padding: 6rpx 8rpx; | |
| 957 | + box-sizing: border-box; | |
| 958 | + overflow: hidden; | |
| 959 | +} | |
| 960 | + | |
| 961 | +.cat-visual-text { | |
| 962 | + width: 100%; | |
| 963 | + max-height: 100%; | |
| 964 | + font-size: 18rpx; | |
| 860 | 965 | font-weight: 700; |
| 861 | 966 | color: #111827; |
| 862 | 967 | line-height: 1.15; |
| ... | ... | @@ -864,27 +969,60 @@ const goBluetoothPage = () => { |
| 864 | 969 | white-space: pre-wrap; |
| 865 | 970 | word-break: break-word; |
| 866 | 971 | overflow: hidden; |
| 972 | +} | |
| 973 | + | |
| 974 | +.cat-visual-text--on-color { | |
| 975 | + color: #ffffff; | |
| 976 | +} | |
| 977 | + | |
| 978 | +.cat-visual-fallback { | |
| 979 | + width: 100%; | |
| 980 | + height: 100%; | |
| 981 | + display: flex; | |
| 982 | + align-items: center; | |
| 983 | + justify-content: center; | |
| 984 | + padding: 6rpx; | |
| 867 | 985 | box-sizing: border-box; |
| 868 | 986 | } |
| 869 | 987 | |
| 870 | -.cat-icon-text--on-color { | |
| 871 | - max-width: 100%; | |
| 988 | +.cat-visual-fallback.bg-red { | |
| 989 | + background: #ef4444; | |
| 990 | +} | |
| 991 | +.cat-visual-fallback.bg-blue { | |
| 992 | + background: #3b82f6; | |
| 993 | +} | |
| 994 | +.cat-visual-fallback.bg-green { | |
| 995 | + background: #22c55e; | |
| 996 | +} | |
| 997 | +.cat-visual-fallback.bg-orange { | |
| 998 | + background: #f97316; | |
| 999 | +} | |
| 1000 | +.cat-visual-fallback.bg-purple { | |
| 1001 | + background: #a855f7; | |
| 1002 | +} | |
| 1003 | + | |
| 1004 | +.cat-name-band { | |
| 1005 | + flex: 3 1 0; | |
| 1006 | + min-height: 0; | |
| 1007 | + width: 100%; | |
| 1008 | + display: flex; | |
| 1009 | + align-items: center; | |
| 1010 | + justify-content: center; | |
| 872 | 1011 | padding: 4rpx 6rpx; |
| 873 | - font-size: 20rpx; | |
| 874 | - font-weight: 700; | |
| 875 | - line-height: 1.15; | |
| 876 | - text-align: center; | |
| 877 | - white-space: pre-wrap; | |
| 878 | - word-break: break-word; | |
| 879 | - overflow: hidden; | |
| 1012 | + background: #ffffff; | |
| 880 | 1013 | box-sizing: border-box; |
| 881 | 1014 | } |
| 882 | 1015 | |
| 883 | 1016 | .cat-name { |
| 884 | - font-size: 20rpx; | |
| 885 | - color: #4b5563; | |
| 1017 | + font-size: 18rpx; | |
| 1018 | + color: #374151; | |
| 886 | 1019 | text-align: center; |
| 1020 | + line-height: 1.2; | |
| 887 | 1021 | word-break: break-word; |
| 1022 | + overflow: hidden; | |
| 1023 | + display: -webkit-box; | |
| 1024 | + -webkit-box-orient: vertical; | |
| 1025 | + -webkit-line-clamp: 2; | |
| 888 | 1026 | } |
| 889 | 1027 | |
| 890 | 1028 | .cat-item.active .cat-name { |
| ... | ... | @@ -968,25 +1106,42 @@ const goBluetoothPage = () => { |
| 968 | 1106 | } |
| 969 | 1107 | |
| 970 | 1108 | .cat-header-thumb { |
| 1109 | + position: relative; | |
| 971 | 1110 | box-sizing: border-box; |
| 972 | 1111 | } |
| 973 | 1112 | |
| 974 | -.cat-header-thumb--photo, | |
| 975 | -.cat-header-thumb--fallback { | |
| 1113 | +.cat-header-thumb--fixed { | |
| 976 | 1114 | overflow: hidden; |
| 977 | 1115 | } |
| 978 | 1116 | |
| 1117 | +.cat-header-text-inner { | |
| 1118 | + position: relative; | |
| 1119 | + width: 100%; | |
| 1120 | + display: flex; | |
| 1121 | + align-items: center; | |
| 1122 | + justify-content: center; | |
| 1123 | + overflow: hidden; | |
| 1124 | + padding: 4rpx 6rpx; | |
| 1125 | + box-sizing: border-box; | |
| 1126 | +} | |
| 1127 | + | |
| 979 | 1128 | .cat-header .cat-header-thumb .cat-icon-text, |
| 980 | -.cat-header .cat-header-thumb .cat-icon-text--on-color { | |
| 1129 | +.cat-header .cat-header-thumb .cat-icon-text--on-color, | |
| 1130 | +.cat-header .cat-header-thumb .cat-icon-text--header { | |
| 981 | 1131 | font-size: 20rpx; |
| 982 | 1132 | line-height: 1.15; |
| 983 | - padding: 4rpx 6rpx; | |
| 1133 | + padding: 0; | |
| 984 | 1134 | max-width: 100%; |
| 985 | 1135 | white-space: pre-wrap; |
| 986 | 1136 | word-break: break-word; |
| 987 | 1137 | overflow: hidden; |
| 988 | 1138 | } |
| 989 | 1139 | |
| 1140 | +.cat-header-thumb--photo, | |
| 1141 | +.cat-header-thumb--fallback { | |
| 1142 | + overflow: hidden; | |
| 1143 | +} | |
| 1144 | + | |
| 990 | 1145 | .cat-header-color-fill { |
| 991 | 1146 | width: 100%; |
| 992 | 1147 | height: 100%; |
| ... | ... | @@ -1062,161 +1217,182 @@ const goBluetoothPage = () => { |
| 1062 | 1217 | flex-wrap: wrap; |
| 1063 | 1218 | align-content: flex-start; |
| 1064 | 1219 | padding-top: 8rpx; |
| 1065 | - margin-left: -5rpx; | |
| 1066 | - margin-right: -5rpx; | |
| 1220 | + margin-left: -6rpx; | |
| 1221 | + margin-right: -6rpx; | |
| 1067 | 1222 | } |
| 1068 | 1223 | |
| 1224 | +/* Product:外框 3:2,上 70% 视觉 + 中 20% 名称 + 下 10% 尺寸/类型 */ | |
| 1069 | 1225 | .food-card { |
| 1070 | 1226 | width: 47%; |
| 1071 | - max-width: 240rpx; | |
| 1227 | + max-width: 260rpx; | |
| 1072 | 1228 | flex: 0 0 auto; |
| 1073 | 1229 | box-sizing: border-box; |
| 1074 | - margin: 0 5rpx 12rpx 5rpx; | |
| 1075 | - background: #f9fafb; | |
| 1076 | - padding: 8rpx; | |
| 1077 | - border-radius: 12rpx; | |
| 1230 | + margin: 0 6rpx 14rpx 6rpx; | |
| 1231 | + min-width: 0; | |
| 1078 | 1232 | } |
| 1079 | 1233 | |
| 1080 | -.food-card:active { | |
| 1081 | - background: #f3f4f6; | |
| 1234 | +.food-card:active .food-card-frame { | |
| 1235 | + opacity: 0.92; | |
| 1082 | 1236 | } |
| 1083 | 1237 | |
| 1084 | -.food-img-wrap { | |
| 1238 | +.food-card-frame { | |
| 1085 | 1239 | width: 100%; |
| 1086 | 1240 | position: relative; |
| 1087 | 1241 | height: 0; |
| 1088 | - padding-top: 58%; | |
| 1089 | - border-radius: 8rpx; | |
| 1090 | - background: #e5e7eb; | |
| 1091 | - margin-bottom: 8rpx; | |
| 1242 | + padding-top: 66.6667%; | |
| 1243 | + border-radius: 12rpx; | |
| 1092 | 1244 | overflow: hidden; |
| 1245 | + background: #ffffff; | |
| 1246 | + border: 1rpx solid #e5e7eb; | |
| 1247 | + box-sizing: border-box; | |
| 1093 | 1248 | } |
| 1094 | 1249 | |
| 1095 | -.food-img-wrap--fallback { | |
| 1096 | - display: flex; | |
| 1097 | - align-items: center; | |
| 1098 | - justify-content: center; | |
| 1099 | -} | |
| 1100 | - | |
| 1101 | -.food-img { | |
| 1250 | +.food-card-inner { | |
| 1102 | 1251 | position: absolute; |
| 1103 | 1252 | left: 0; |
| 1104 | 1253 | top: 0; |
| 1254 | + right: 0; | |
| 1255 | + bottom: 0; | |
| 1256 | + display: flex; | |
| 1257 | + flex-direction: column; | |
| 1258 | +} | |
| 1259 | + | |
| 1260 | +.food-visual { | |
| 1261 | + flex: 7 1 0; | |
| 1262 | + min-height: 0; | |
| 1263 | + width: 100%; | |
| 1264 | + position: relative; | |
| 1265 | + overflow: hidden; | |
| 1266 | + background: #f3f4f6; | |
| 1267 | + box-sizing: border-box; | |
| 1268 | +} | |
| 1269 | + | |
| 1270 | +.food-img-fill { | |
| 1271 | + width: 100%; | |
| 1272 | + height: 100%; | |
| 1273 | + display: block; | |
| 1274 | +} | |
| 1275 | + | |
| 1276 | +.food-visual-color-fill { | |
| 1105 | 1277 | width: 100%; |
| 1106 | 1278 | height: 100%; |
| 1107 | 1279 | } |
| 1108 | 1280 | |
| 1109 | -.food-thumb-text { | |
| 1110 | - position: absolute; | |
| 1111 | - left: 0; | |
| 1112 | - top: 0; | |
| 1281 | +.food-visual-text-wrap { | |
| 1113 | 1282 | width: 100%; |
| 1114 | 1283 | height: 100%; |
| 1115 | 1284 | display: flex; |
| 1116 | 1285 | align-items: center; |
| 1117 | 1286 | justify-content: center; |
| 1118 | - padding: 8rpx; | |
| 1287 | + padding: 8rpx 10rpx; | |
| 1119 | 1288 | box-sizing: border-box; |
| 1120 | - font-size: 26rpx; | |
| 1289 | + overflow: hidden; | |
| 1290 | +} | |
| 1291 | + | |
| 1292 | +.food-visual-text { | |
| 1293 | + width: 100%; | |
| 1294 | + max-height: 100%; | |
| 1295 | + font-size: 20rpx; | |
| 1121 | 1296 | font-weight: 700; |
| 1122 | 1297 | color: #111827; |
| 1123 | - line-height: 1.15; | |
| 1298 | + line-height: 1.2; | |
| 1124 | 1299 | text-align: center; |
| 1300 | + white-space: pre-wrap; | |
| 1125 | 1301 | word-break: break-word; |
| 1302 | + overflow: hidden; | |
| 1126 | 1303 | } |
| 1127 | 1304 | |
| 1128 | -.food-thumb-text--on-color { | |
| 1129 | - font-size: 24rpx; | |
| 1305 | +.food-visual-text--on-color { | |
| 1130 | 1306 | color: #ffffff; |
| 1131 | 1307 | } |
| 1132 | 1308 | |
| 1133 | -.food-thumb-color-fill { | |
| 1134 | - position: absolute; | |
| 1135 | - left: 0; | |
| 1136 | - top: 0; | |
| 1137 | - width: 100%; | |
| 1138 | - height: 100%; | |
| 1139 | -} | |
| 1140 | - | |
| 1141 | -.food-thumb-fallback { | |
| 1142 | - position: absolute; | |
| 1143 | - left: 0; | |
| 1144 | - top: 0; | |
| 1309 | +.food-visual-fallback { | |
| 1145 | 1310 | width: 100%; |
| 1146 | 1311 | height: 100%; |
| 1147 | 1312 | display: flex; |
| 1148 | 1313 | align-items: center; |
| 1149 | 1314 | justify-content: center; |
| 1150 | - padding: 12rpx; | |
| 1315 | + padding: 8rpx; | |
| 1151 | 1316 | box-sizing: border-box; |
| 1152 | 1317 | } |
| 1153 | 1318 | |
| 1154 | -.food-thumb-fallback.bg-red { | |
| 1319 | +.food-visual-fallback.bg-red { | |
| 1155 | 1320 | background: #ef4444; |
| 1156 | 1321 | } |
| 1157 | -.food-thumb-fallback.bg-blue { | |
| 1322 | +.food-visual-fallback.bg-blue { | |
| 1158 | 1323 | background: #3b82f6; |
| 1159 | 1324 | } |
| 1160 | -.food-thumb-fallback.bg-green { | |
| 1325 | +.food-visual-fallback.bg-green { | |
| 1161 | 1326 | background: #22c55e; |
| 1162 | 1327 | } |
| 1163 | -.food-thumb-fallback.bg-orange { | |
| 1328 | +.food-visual-fallback.bg-orange { | |
| 1164 | 1329 | background: #f97316; |
| 1165 | 1330 | } |
| 1166 | -.food-thumb-fallback.bg-purple { | |
| 1331 | +.food-visual-fallback.bg-purple { | |
| 1167 | 1332 | background: #a855f7; |
| 1168 | 1333 | } |
| 1169 | 1334 | |
| 1170 | -.size-badge { | |
| 1171 | - position: absolute; | |
| 1172 | - left: 8rpx; | |
| 1173 | - top: 8rpx; | |
| 1174 | - background: rgba(0, 0, 0, 0.25); | |
| 1175 | - padding: 4rpx 12rpx; | |
| 1176 | - border-radius: 8rpx; | |
| 1177 | -} | |
| 1178 | - | |
| 1179 | -.size-badge-text { | |
| 1180 | - font-size: 18rpx; | |
| 1181 | - color: #fff; | |
| 1182 | - font-weight: 500; | |
| 1183 | -} | |
| 1184 | - | |
| 1185 | -.type-badge { | |
| 1186 | - position: absolute; | |
| 1187 | - right: 8rpx; | |
| 1188 | - bottom: 8rpx; | |
| 1189 | - background: rgba(0, 0, 0, 0.25); | |
| 1190 | - padding: 4rpx 12rpx; | |
| 1191 | - border-radius: 8rpx; | |
| 1192 | -} | |
| 1193 | - | |
| 1194 | -.type-badge-text { | |
| 1195 | - font-size: 18rpx; | |
| 1196 | - color: #fff; | |
| 1197 | - font-weight: 500; | |
| 1335 | +.food-name-band { | |
| 1336 | + flex: 2 1 0; | |
| 1337 | + min-height: 0; | |
| 1338 | + width: 100%; | |
| 1339 | + display: flex; | |
| 1340 | + align-items: center; | |
| 1341 | + justify-content: center; | |
| 1342 | + padding: 2rpx 8rpx; | |
| 1343 | + background: #ffffff; | |
| 1344 | + box-sizing: border-box; | |
| 1198 | 1345 | } |
| 1199 | 1346 | |
| 1200 | 1347 | .food-name { |
| 1201 | - font-size: 22rpx; | |
| 1348 | + width: 100%; | |
| 1349 | + font-size: 20rpx; | |
| 1202 | 1350 | font-weight: 600; |
| 1203 | 1351 | color: #111827; |
| 1204 | - display: block; | |
| 1205 | - margin-bottom: 2rpx; | |
| 1352 | + line-height: 1.2; | |
| 1353 | + text-align: center; | |
| 1354 | + word-break: break-word; | |
| 1206 | 1355 | overflow: hidden; |
| 1207 | - text-overflow: ellipsis; | |
| 1208 | - white-space: nowrap; | |
| 1356 | + display: -webkit-box; | |
| 1357 | + -webkit-box-orient: vertical; | |
| 1358 | + -webkit-line-clamp: 2; | |
| 1209 | 1359 | } |
| 1210 | 1360 | |
| 1211 | -.food-desc { | |
| 1212 | - font-size: 18rpx; | |
| 1361 | +.food-meta-band { | |
| 1362 | + flex: 1 1 0; | |
| 1363 | + min-height: 0; | |
| 1364 | + width: 100%; | |
| 1365 | + display: flex; | |
| 1366 | + flex-direction: row; | |
| 1367 | + align-items: center; | |
| 1368 | + justify-content: space-between; | |
| 1369 | + gap: 4rpx; | |
| 1370 | + padding: 0 8rpx 4rpx; | |
| 1371 | + background: #ffffff; | |
| 1372 | + box-sizing: border-box; | |
| 1373 | +} | |
| 1374 | + | |
| 1375 | +.food-meta-size, | |
| 1376 | +.food-meta-types { | |
| 1377 | + flex: 1; | |
| 1378 | + min-width: 0; | |
| 1379 | + font-size: 14rpx; | |
| 1213 | 1380 | color: #6b7280; |
| 1214 | - display: block; | |
| 1381 | + font-weight: 500; | |
| 1382 | + line-height: 1.15; | |
| 1215 | 1383 | overflow: hidden; |
| 1216 | 1384 | text-overflow: ellipsis; |
| 1217 | 1385 | white-space: nowrap; |
| 1218 | 1386 | } |
| 1219 | 1387 | |
| 1388 | +.food-meta-size { | |
| 1389 | + text-align: left; | |
| 1390 | +} | |
| 1391 | + | |
| 1392 | +.food-meta-types { | |
| 1393 | + text-align: right; | |
| 1394 | +} | |
| 1395 | + | |
| 1220 | 1396 | .modal-mask { |
| 1221 | 1397 | position: fixed; |
| 1222 | 1398 | top: 0; |
| ... | ... | @@ -1306,21 +1482,27 @@ const goBluetoothPage = () => { |
| 1306 | 1482 | padding: 20rpx 24rpx; |
| 1307 | 1483 | } |
| 1308 | 1484 | |
| 1309 | - /* 平板:内联样式在运行时由 rpx 换算,此处仅作 H5 预览补充 */ | |
| 1310 | - | |
| 1311 | 1485 | .food-card { |
| 1312 | 1486 | width: 31%; |
| 1313 | - max-width: 280rpx; | |
| 1314 | - padding: 10rpx; | |
| 1315 | - margin: 0 7rpx 14rpx 7rpx; | |
| 1487 | + max-width: 300rpx; | |
| 1488 | + margin: 0 8rpx 16rpx 8rpx; | |
| 1316 | 1489 | } |
| 1317 | 1490 | |
| 1318 | - .food-img-wrap { | |
| 1319 | - padding-top: 62%; | |
| 1491 | + .food-name { | |
| 1492 | + font-size: 22rpx; | |
| 1493 | + } | |
| 1494 | + | |
| 1495 | + .food-meta-size, | |
| 1496 | + .food-meta-types { | |
| 1497 | + font-size: 16rpx; | |
| 1320 | 1498 | } |
| 1321 | 1499 | |
| 1322 | 1500 | .sidebar { |
| 1323 | 1501 | width: 260rpx; |
| 1324 | 1502 | } |
| 1503 | + | |
| 1504 | + .cat-card-frame { | |
| 1505 | + max-width: 220rpx; | |
| 1506 | + } | |
| 1325 | 1507 | } |
| 1326 | 1508 | </style> | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
| ... | ... | @@ -24,30 +24,13 @@ |
| 24 | 24 | </view> |
| 25 | 25 | |
| 26 | 26 | <template v-else> |
| 27 | - <view class="food-card"> | |
| 28 | - <view class="food-info"> | |
| 29 | - <text class="food-name">{{ displayProductName }}</text> | |
| 30 | - <text class="food-cat">{{ productCategory }}</text> | |
| 31 | - <view v-if="labelTypeName" class="food-label-type"> | |
| 32 | - <AppIcon name="tag" size="sm" color="primary" /> | |
| 33 | - <text class="food-label-type-text">{{ labelTypeName }}</text> | |
| 34 | - </view> | |
| 35 | - </view> | |
| 36 | - <view class="food-template"> | |
| 37 | - <text class="template-size">{{ templateSize }}</text> | |
| 38 | - <text class="template-name">{{ templateName }}</text> | |
| 39 | - </view> | |
| 40 | - </view> | |
| 27 | + <text class="section-title section-title-first">Label Preview</text> | |
| 41 | 28 | |
| 42 | - <view class="qty-card"> | |
| 43 | - <text class="qty-label">Print Quantity</text> | |
| 44 | - <view class="qty-control"> | |
| 45 | - <view class="qty-btn" :class="{ disabled: printQty <= 1 }" @click="decrement"> | |
| 46 | - <AppIcon name="minus" size="sm" color="gray" /> | |
| 47 | - </view> | |
| 48 | - <text class="qty-value">{{ printQty }}</text> | |
| 49 | - <view class="qty-btn" @click="increment"> | |
| 50 | - <AppIcon name="plus" size="sm" color="gray" /> | |
| 29 | + <view class="label-card"> | |
| 30 | + <view class="label-img-wrap"> | |
| 31 | + <image v-if="previewImageSrc" :src="previewImageSrc" class="label-img" mode="widthFix" /> | |
| 32 | + <view v-else class="label-placeholder"> | |
| 33 | + <text class="label-placeholder-text">No preview image</text> | |
| 51 | 34 | </view> |
| 52 | 35 | </view> |
| 53 | 36 | </view> |
| ... | ... | @@ -74,13 +57,28 @@ |
| 74 | 57 | </view> |
| 75 | 58 | |
| 76 | 59 | <view v-if="printFreeFieldList.length" class="print-options-card"> |
| 77 | - <text class="print-options-title">Print input</text> | |
| 78 | - <text class="print-options-hint"> | |
| 79 | - with a box below. If the template has a unit, it is appended on the label after what you type. | |
| 80 | - </text> | |
| 60 | + <text class="print-input-prompt">Please enter below value(s) to print the label:</text> | |
| 81 | 61 | <view v-for="el in printFreeFieldList" :key="'free-' + el.id" class="print-option-block"> |
| 82 | 62 | <text class="print-option-label">{{ freeFieldNameLabel(el) }}</text> |
| 83 | - <template v-if="freeFieldInputKind(el) === 'text'"> | |
| 63 | + <template v-if="isWeightPrintField(el)"> | |
| 64 | + <input | |
| 65 | + class="free-field-input" | |
| 66 | + type="digit" | |
| 67 | + :value="printFreeFieldValues[el.id] ?? ''" | |
| 68 | + :placeholder="weightInputPlaceholder(readWeightInputMode(el.config || {}))" | |
| 69 | + @input="onFreeFieldInput(el.id, $event)" | |
| 70 | + /> | |
| 71 | + </template> | |
| 72 | + <template v-else-if="freeFieldInputKind(el) === 'number'"> | |
| 73 | + <input | |
| 74 | + class="free-field-input" | |
| 75 | + type="digit" | |
| 76 | + :value="printFreeFieldValues[el.id] ?? ''" | |
| 77 | + placeholder="0" | |
| 78 | + @input="onFreeFieldInput(el.id, $event)" | |
| 79 | + /> | |
| 80 | + </template> | |
| 81 | + <template v-else-if="freeFieldInputKind(el) === 'text'"> | |
| 84 | 82 | <input |
| 85 | 83 | class="free-field-input" |
| 86 | 84 | type="text" |
| ... | ... | @@ -116,36 +114,42 @@ |
| 116 | 114 | </view> |
| 117 | 115 | </view> |
| 118 | 116 | |
| 119 | - <text class="section-title">Label Preview</text> | |
| 120 | - | |
| 121 | - <view class="label-card"> | |
| 122 | - <view class="label-img-wrap"> | |
| 123 | - <image v-if="previewImageSrc" :src="previewImageSrc" class="label-img" mode="widthFix" /> | |
| 124 | - <view v-else class="label-placeholder"> | |
| 125 | - <text class="label-placeholder-text">No preview image</text> | |
| 117 | + <scroll-view class="meta-footer-scroll" scroll-x enable-flex> | |
| 118 | + <view class="meta-footer-row"> | |
| 119 | + <view class="meta-item"> | |
| 120 | + <text class="info-label">Product</text> | |
| 121 | + <text class="info-value">{{ displayProductName || '—' }}</text> | |
| 122 | + </view> | |
| 123 | + <view class="meta-item"> | |
| 124 | + <text class="info-label">Category</text> | |
| 125 | + <text class="info-value">{{ productCategory || '—' }}</text> | |
| 126 | + </view> | |
| 127 | + <view v-if="labelTypeName" class="meta-item"> | |
| 128 | + <text class="info-label">Type</text> | |
| 129 | + <text class="info-value">{{ labelTypeName }}</text> | |
| 130 | + </view> | |
| 131 | + <view class="meta-item"> | |
| 132 | + <text class="info-label">Size</text> | |
| 133 | + <text class="info-value">{{ templateSize || '—' }}</text> | |
| 134 | + </view> | |
| 135 | + <view class="meta-item"> | |
| 136 | + <text class="info-label">Template</text> | |
| 137 | + <text class="info-value">{{ templateName || '—' }}</text> | |
| 138 | + </view> | |
| 139 | + <view class="meta-item"> | |
| 140 | + <text class="info-label">Label ID</text> | |
| 141 | + <text class="info-value">{{ footerLabelIdDisplay }}</text> | |
| 142 | + </view> | |
| 143 | + <view class="meta-item"> | |
| 144 | + <text class="info-label">Last Edited</text> | |
| 145 | + <text class="info-value">{{ lastEdited }}</text> | |
| 146 | + </view> | |
| 147 | + <view class="meta-item"> | |
| 148 | + <text class="info-label">Location</text> | |
| 149 | + <text class="info-value">{{ locationName }}</text> | |
| 126 | 150 | </view> |
| 127 | 151 | </view> |
| 128 | - </view> | |
| 129 | - | |
| 130 | - <view class="info-row"> | |
| 131 | - <view class="info-item"> | |
| 132 | - <text class="info-label">Label ID</text> | |
| 133 | - <text class="info-value">{{ labelIdDisplay }}</text> | |
| 134 | - </view> | |
| 135 | - <view class="info-item"> | |
| 136 | - <text class="info-label">Last Edited</text> | |
| 137 | - <text class="info-value">{{ lastEdited }}</text> | |
| 138 | - </view> | |
| 139 | - <view class="info-item"> | |
| 140 | - <text class="info-label">Location</text> | |
| 141 | - <text class="info-value">{{ locationName }}</text> | |
| 142 | - </view> | |
| 143 | - </view> | |
| 144 | - | |
| 145 | - <view class="note-card"> | |
| 146 | - <AppIcon name="alert" size="sm" color="blue" /> | |
| 147 | - <text class="note-text">This is a preview of the label. Actual printed labels may vary slightly in appearance.</text> | |
| 148 | - </view> | |
| 152 | + </scroll-view> | |
| 149 | 153 | </template> |
| 150 | 154 | </view> |
| 151 | 155 | |
| ... | ... | @@ -161,6 +165,15 @@ |
| 161 | 165 | <view class="btn-preview-sq" @click="showPreviewModal = true"> |
| 162 | 166 | <AppIcon name="eye" size="md" color="primary" /> |
| 163 | 167 | </view> |
| 168 | + <view class="bottom-qty-control"> | |
| 169 | + <view class="qty-btn qty-btn-minus" :class="{ disabled: printQty <= 1 }" @click="decrement"> | |
| 170 | + <AppIcon name="minus" size="sm" color="gray" /> | |
| 171 | + </view> | |
| 172 | + <text class="qty-value">{{ printQty }}</text> | |
| 173 | + <view class="qty-btn qty-btn-plus" @click="increment"> | |
| 174 | + <AppIcon name="plus" size="sm" color="white" /> | |
| 175 | + </view> | |
| 176 | + </view> | |
| 164 | 177 | <view class="print-btn" :class="{ disabled: isPrinting || previewLoading || !systemTemplate }" @click="handlePrint"> |
| 165 | 178 | <AppIcon name="printer" size="sm" color="white" /> |
| 166 | 179 | <text class="print-btn-text">{{ isPrinting ? 'Printing...' : 'Print' }}</text> |
| ... | ... | @@ -174,10 +187,6 @@ |
| 174 | 187 | <view class="modal-label-inner"> |
| 175 | 188 | <image :src="previewImageSrc" class="modal-label-img" mode="widthFix" /> |
| 176 | 189 | </view> |
| 177 | - <view class="modal-label-id"> | |
| 178 | - <text class="modal-label-id-label">Label ID</text> | |
| 179 | - <text class="modal-label-id-value">{{ labelIdDisplay }}</text> | |
| 180 | - </view> | |
| 181 | 190 | </view> |
| 182 | 191 | </view> |
| 183 | 192 | </view> |
| ... | ... | @@ -232,7 +241,7 @@ |
| 232 | 241 | </template> |
| 233 | 242 | |
| 234 | 243 | <script setup lang="ts"> |
| 235 | -import { ref, computed, getCurrentInstance, nextTick } from 'vue' | |
| 244 | +import { ref, computed, getCurrentInstance, nextTick, watch } from 'vue' | |
| 236 | 245 | import { onLoad, onShow, onUnload } from '@dcloudio/uni-app' |
| 237 | 246 | import AppIcon from '../../components/AppIcon.vue' |
| 238 | 247 | import SideMenu from '../../components/SideMenu.vue' |
| ... | ... | @@ -263,12 +272,18 @@ import { |
| 263 | 272 | isPrintInputOptionsElement, |
| 264 | 273 | mergePrintInputFreeFields, |
| 265 | 274 | mergePrintOptionSelections, |
| 275 | + printInputFieldLabelFromElement, | |
| 276 | + printInputPreText, | |
| 266 | 277 | readFreeFieldValuesFromTemplate, |
| 267 | 278 | readSelectionsFromTemplate, |
| 268 | 279 | validatePrintInputFreeFieldsBeforePrint, |
| 269 | 280 | validatePrintInputOptionsBeforePrint, |
| 270 | 281 | } from '../../utils/labelPreview/printInputOptions' |
| 271 | 282 | import { |
| 283 | + readWeightInputMode, | |
| 284 | + weightInputPlaceholder, | |
| 285 | +} from '../../utils/weightElement' | |
| 286 | +import { | |
| 272 | 287 | buildLabelPrintJobPayload, |
| 273 | 288 | setLastLabelPrintJobPayload, |
| 274 | 289 | } from '../../utils/labelPreview/buildLabelPrintPayload' |
| ... | ... | @@ -285,19 +300,32 @@ import { |
| 285 | 300 | } from '../../services/usAppLabeling' |
| 286 | 301 | import { |
| 287 | 302 | applyTemplateProductDefaultValuesToTemplate, |
| 288 | - applyProductCodeValueToTemplateScanElements, | |
| 289 | 303 | applyLabelBindingsToPreviewTemplate, |
| 304 | + applyLabelSizeTextToTemplate, | |
| 305 | + applyProductCodeValueToTemplateScanElements, | |
| 306 | + expandNutritionElementsToFitContent, | |
| 290 | 307 | extractProductCodeValueFromPreviewPayload, |
| 291 | 308 | extractTemplateProductDefaultValuesFromPreviewPayload, |
| 292 | 309 | normalizeLabelTemplateFromPreviewApi, |
| 293 | 310 | overlayProductNameOnPreviewTemplate, |
| 294 | 311 | } from '../../utils/labelPreview/normalizePreviewTemplate' |
| 295 | 312 | import { |
| 313 | + applyLabelIdDisplayToTemplate, | |
| 314 | + templateIncludesLabelIdElement, | |
| 315 | +} from '../../utils/labelPreview/labelIdElement' | |
| 316 | +import { | |
| 317 | + applyEmployeeDisplayToTemplate, | |
| 318 | + templateIncludesEmployeeElement, | |
| 319 | +} from '../../utils/labelPreview/employeeElement' | |
| 320 | +import { applyLiveDateTimeFieldsToTemplate } from '../../utils/labelPreview/printInputOffset' | |
| 321 | +import { readInvertColors } from '../../utils/invertColorsConfig' | |
| 322 | +import { | |
| 296 | 323 | getLabelPrintRasterLayout, |
| 297 | 324 | getPreviewCanvasCssSize, |
| 298 | 325 | renderLabelPreviewCanvasImageDataForPrint, |
| 299 | 326 | renderLabelPreviewCanvasToTempPathForPrint, |
| 300 | 327 | renderLabelPreviewToTempPath, |
| 328 | + settleAfterLabelCanvasResize, | |
| 301 | 329 | } from '../../utils/labelPreview/renderLabelPreviewCanvas' |
| 302 | 330 | import { |
| 303 | 331 | hydrateSystemTemplateImagesForPrint, |
| ... | ... | @@ -305,13 +333,16 @@ import { |
| 305 | 333 | } from '../../utils/print/hydrateTemplateImagesForPrint' |
| 306 | 334 | import { |
| 307 | 335 | normalizeTemplateForNativeFastJob, |
| 336 | + templateHasDateTimeElements, | |
| 308 | 337 | templateHasUnsupportedNativeFastElements, |
| 338 | + templateRequiresCanvasStyleFidelity, | |
| 309 | 339 | } from '../../utils/print/nativeTemplateElementSupport' |
| 340 | +import { isVirtualBtPrinterDeviceName } from '../../utils/print/bluetoothPrinterAllowlist' | |
| 310 | 341 | import { |
| 311 | 342 | ensureTemplateHeightCoversElements, |
| 312 | 343 | getTemplatePhysicalSizeMm, |
| 313 | 344 | isTemplateWithinNativeFastPrintBounds, |
| 314 | - templateContentHeightPx, | |
| 345 | + rasterDotsToMillimeters, | |
| 315 | 346 | } from '../../utils/print/templatePhysicalMm' |
| 316 | 347 | import { isPrinterReadySync } from '../../utils/print/printerReadiness' |
| 317 | 348 | import { |
| ... | ... | @@ -320,7 +351,13 @@ import { |
| 320 | 351 | isNativeBaseClassicBluetoothTransport, |
| 321 | 352 | getPrinterDebugSnapshot, |
| 322 | 353 | preferBuiltinPrinterOnAioDevice, |
| 354 | + restoreD320faxVirtualBtBluetoothMode, | |
| 323 | 355 | } from '../../utils/print/printerConnection' |
| 356 | +import { | |
| 357 | + persistPrintRunDiagnostics, | |
| 358 | + printRunDiag, | |
| 359 | + startPrintRunDiagnostics, | |
| 360 | +} from '../../utils/print/printRunDiagnostics' | |
| 324 | 361 | import { isUsAppSessionExpiredError } from '../../utils/usAppApiRequest' |
| 325 | 362 | import { getDeviceFingerprint } from '../../utils/deviceInfo' |
| 326 | 363 | |
| ... | ... | @@ -666,6 +703,9 @@ function applyNativeTemplateStyleScale( |
| 666 | 703 | // 时间/日期类右对齐文本在部分机型原生 TEXT 宽度估算有误差,强制位图可避免截断。 |
| 667 | 704 | cfg.forceRasterText = true |
| 668 | 705 | } |
| 706 | + if (readInvertColors(cfg)) { | |
| 707 | + cfg.forceRasterText = true | |
| 708 | + } | |
| 669 | 709 | } |
| 670 | 710 | if (type === 'BARCODE') { |
| 671 | 711 | const raw = String( |
| ... | ... | @@ -803,7 +843,13 @@ const templateSize = ref('') |
| 803 | 843 | const templateName = ref('') |
| 804 | 844 | const lastEdited = ref('') |
| 805 | 845 | const locationName = ref('') |
| 806 | -const labelIdDisplay = ref('') | |
| 846 | +/** 模板含 Label ID 控件时,接口返回的门店当日打印序号(yyyyMMdd-n),用于画布预览/出纸 */ | |
| 847 | +const printLabelDisplayId = ref('') | |
| 848 | + | |
| 849 | +const footerLabelIdDisplay = computed(() => { | |
| 850 | + const id = printLabelDisplayId.value.trim() | |
| 851 | + return id || '—' | |
| 852 | +}) | |
| 807 | 853 | |
| 808 | 854 | const previewLoading = ref(true) |
| 809 | 855 | const previewError = ref('') |
| ... | ... | @@ -811,6 +857,10 @@ const previewImageSrc = ref('') |
| 811 | 857 | const systemTemplate = ref<SystemLabelTemplate | null>(null) |
| 812 | 858 | /** 未合并「打印多选项」前的模板,用于反复 merge */ |
| 813 | 859 | const basePreviewTemplate = ref<SystemLabelTemplate | null>(null) |
| 860 | +/** 接口返回的 templateProductDefaults,每次合并/重绘按当前时刻重算日期时间 */ | |
| 861 | +const previewProductDefaults = ref<Record<string, string>>({}) | |
| 862 | +/** 最近一次 8.2 原始响应,便于 defaults 解析失败时重试 */ | |
| 863 | +const lastPreviewRawPayload = ref<unknown>(null) | |
| 814 | 864 | const printOptionSelections = ref<Record<string, string[]>>({}) |
| 815 | 865 | const dictLabelsByElementId = ref<Record<string, string>>({}) |
| 816 | 866 | const dictValuesByElementId = ref<Record<string, string[]>>({}) |
| ... | ... | @@ -865,6 +915,8 @@ function dictValuesForElement(elementId: string): string[] { |
| 865 | 915 | } |
| 866 | 916 | |
| 867 | 917 | function optionFieldTitle(el: SystemTemplateElementBase): string { |
| 918 | + const preText = printInputPreText(el) | |
| 919 | + if (preText) return preText | |
| 868 | 920 | return ( |
| 869 | 921 | dictLabelsByElementId.value[el.id] || |
| 870 | 922 | el.elementName || |
| ... | ... | @@ -890,13 +942,19 @@ function togglePrintOption(elementId: string, value: string) { |
| 890 | 942 | } |
| 891 | 943 | |
| 892 | 944 | function freeFieldNameLabel(el: SystemTemplateElementBase): string { |
| 893 | - const n = el.elementName || el.inputKey || 'Field' | |
| 894 | - return `${n}:` | |
| 945 | + return printInputFieldLabelFromElement(el) | |
| 946 | +} | |
| 947 | + | |
| 948 | +function isWeightPrintField(el: SystemTemplateElementBase): boolean { | |
| 949 | + return String(el.type || '').toUpperCase() === 'WEIGHT' | |
| 895 | 950 | } |
| 896 | 951 | |
| 897 | 952 | function freeFieldPlaceholder(el: SystemTemplateElementBase): string { |
| 898 | 953 | const c = el.config || {} |
| 899 | 954 | const type = String(el.type || '').toUpperCase() |
| 955 | + if (type === 'WEIGHT') { | |
| 956 | + return weightInputPlaceholder(readWeightInputMode(c)) | |
| 957 | + } | |
| 900 | 958 | if (type === 'DATE' || type === 'TIME') { |
| 901 | 959 | return String(c.format ?? c.Format ?? '') |
| 902 | 960 | } |
| ... | ... | @@ -908,10 +966,11 @@ function freeFieldDateFormat(el: SystemTemplateElementBase): string { |
| 908 | 966 | return String(c.format ?? c.Format ?? '').trim() |
| 909 | 967 | } |
| 910 | 968 | |
| 911 | -function freeFieldInputKind(el: SystemTemplateElementBase): 'text' | 'date' | 'time' | 'datetime' { | |
| 969 | +function freeFieldInputKind(el: SystemTemplateElementBase): 'text' | 'number' | 'date' | 'time' | 'datetime' { | |
| 912 | 970 | const type = String(el.type || '').toUpperCase() |
| 913 | 971 | const c = el.config || {} |
| 914 | 972 | const inputType = String(c.inputType ?? c.InputType ?? '').toLowerCase() |
| 973 | + if (inputType === 'number') return 'number' | |
| 915 | 974 | if (type === 'TIME') return 'time' |
| 916 | 975 | if (type === 'DATE' && inputType === 'datetime') return 'datetime' |
| 917 | 976 | if (type === 'DATE') return 'date' |
| ... | ... | @@ -1149,21 +1208,41 @@ function onFreeFieldInput(elementId: string, e: { detail?: { value?: string } }) |
| 1149 | 1208 | }, 280) |
| 1150 | 1209 | } |
| 1151 | 1210 | |
| 1152 | -function computeMergedPreviewTemplate(): SystemLabelTemplate | null { | |
| 1153 | - const base = basePreviewTemplate.value | |
| 1211 | +function computeMergedPreviewTemplate(at?: Date): SystemLabelTemplate | null { | |
| 1212 | + let base = basePreviewTemplate.value | |
| 1154 | 1213 | if (!base) return null |
| 1214 | + const baseTime = at ?? new Date() | |
| 1215 | + let defaults = previewProductDefaults.value | |
| 1216 | + if (Object.keys(defaults).length === 0 && lastPreviewRawPayload.value != null) { | |
| 1217 | + const recovered = extractTemplateProductDefaultValuesFromPreviewPayload(lastPreviewRawPayload.value) | |
| 1218 | + if (Object.keys(recovered).length > 0) { | |
| 1219 | + defaults = recovered | |
| 1220 | + previewProductDefaults.value = recovered | |
| 1221 | + } | |
| 1222 | + } | |
| 1223 | + if (Object.keys(defaults).length > 0) { | |
| 1224 | + base = applyTemplateProductDefaultValuesToTemplate(base, defaults, baseTime) | |
| 1225 | + } | |
| 1155 | 1226 | let m = mergePrintOptionSelections( |
| 1156 | 1227 | base, |
| 1157 | 1228 | printOptionSelections.value, |
| 1158 | 1229 | dictLabelsByElementId.value |
| 1159 | 1230 | ) |
| 1160 | 1231 | m = mergePrintInputFreeFields(m, printFreeFieldValues.value) |
| 1232 | + if (templateIncludesLabelIdElement(m) && printLabelDisplayId.value.trim()) { | |
| 1233 | + m = applyLabelIdDisplayToTemplate(m, printLabelDisplayId.value.trim()) | |
| 1234 | + } | |
| 1235 | + if (templateIncludesEmployeeElement(m)) { | |
| 1236 | + m = applyEmployeeDisplayToTemplate(m) | |
| 1237 | + } | |
| 1238 | + m = applyLiveDateTimeFieldsToTemplate(m, baseTime) | |
| 1161 | 1239 | m = applyLabelBindingsToPreviewTemplate(m, { |
| 1162 | 1240 | labelName: previewLabelName.value, |
| 1163 | 1241 | labelTypeName: labelTypeName.value, |
| 1164 | 1242 | productName: displayProductName.value, |
| 1165 | 1243 | }) |
| 1166 | - return m | |
| 1244 | + m = expandNutritionElementsToFitContent(m) | |
| 1245 | + return ensureTemplateHeightCoversElements(m) | |
| 1167 | 1246 | } |
| 1168 | 1247 | |
| 1169 | 1248 | async function loadDictionaryMetaForTemplate(t: SystemLabelTemplate) { |
| ... | ... | @@ -1187,7 +1266,7 @@ async function loadDictionaryMetaForTemplate(t: SystemLabelTemplate) { |
| 1187 | 1266 | dictValuesByElementId.value = values |
| 1188 | 1267 | } |
| 1189 | 1268 | |
| 1190 | -/** 画布 :width/:height 与离屏节点布局在 H5 上晚一拍,过早 canvasToTempFilePath 会裁切底部;双 nextTick + 双 rAF 再导出 */ | |
| 1269 | +/** 画布 :width/:height 在 H5 / App 上可能晚一拍,过早 canvasToTempFilePath 会裁切底部黑条 */ | |
| 1191 | 1270 | async function waitForCanvasLayout(): Promise<void> { |
| 1192 | 1271 | await nextTick() |
| 1193 | 1272 | await nextTick() |
| ... | ... | @@ -1200,6 +1279,13 @@ async function waitForCanvasLayout(): Promise<void> { |
| 1200 | 1279 | setTimeout(() => resolve(), 32) |
| 1201 | 1280 | } |
| 1202 | 1281 | }) |
| 1282 | + try { | |
| 1283 | + if (typeof plus !== 'undefined') { | |
| 1284 | + await new Promise<void>((r) => setTimeout(r, 160)) | |
| 1285 | + } | |
| 1286 | + } catch { | |
| 1287 | + /* H5 */ | |
| 1288 | + } | |
| 1203 | 1289 | } |
| 1204 | 1290 | |
| 1205 | 1291 | /** 光栅打印前同步隐藏 canvas 像素尺寸,避免缓冲区高度仍为预览值导致底部裁切 */ |
| ... | ... | @@ -1259,6 +1345,12 @@ onShow(() => { |
| 1259 | 1345 | } |
| 1260 | 1346 | }) |
| 1261 | 1347 | |
| 1348 | +watch(showPreviewModal, (open) => { | |
| 1349 | + if (open && basePreviewTemplate.value) { | |
| 1350 | + void refreshPreviewFromSelections() | |
| 1351 | + } | |
| 1352 | +}) | |
| 1353 | + | |
| 1262 | 1354 | onUnload(() => { |
| 1263 | 1355 | if (deferredPreviewRedrawTimer != null) { |
| 1264 | 1356 | clearTimeout(deferredPreviewRedrawTimer) |
| ... | ... | @@ -1277,7 +1369,7 @@ onLoad((opts: Record<string, string | undefined>) => { |
| 1277 | 1369 | |
| 1278 | 1370 | const name = uni.getStorageSync('storeName') |
| 1279 | 1371 | if (typeof name === 'string' && name.trim()) locationName.value = name.trim() |
| 1280 | - labelIdDisplay.value = '—' | |
| 1372 | + printLabelDisplayId.value = '' | |
| 1281 | 1373 | lastEdited.value = '—' |
| 1282 | 1374 | |
| 1283 | 1375 | loadPreview() |
| ... | ... | @@ -1306,6 +1398,8 @@ async function loadPreview() { |
| 1306 | 1398 | previewImageSrc.value = '' |
| 1307 | 1399 | systemTemplate.value = null |
| 1308 | 1400 | basePreviewTemplate.value = null |
| 1401 | + previewProductDefaults.value = {} | |
| 1402 | + lastPreviewRawPayload.value = null | |
| 1309 | 1403 | printOptionSelections.value = {} |
| 1310 | 1404 | printFreeFieldValues.value = {} |
| 1311 | 1405 | dictLabelsByElementId.value = {} |
| ... | ... | @@ -1316,7 +1410,9 @@ async function loadPreview() { |
| 1316 | 1410 | locationId: loc, |
| 1317 | 1411 | labelCode: labelCode.value, |
| 1318 | 1412 | productId: productId.value || undefined, |
| 1413 | + baseTime: new Date().toISOString(), | |
| 1319 | 1414 | }) |
| 1415 | + lastPreviewRawPayload.value = raw | |
| 1320 | 1416 | const root = raw as Record<string, unknown> |
| 1321 | 1417 | const nested = root.data ?? root.Data |
| 1322 | 1418 | const inner = |
| ... | ... | @@ -1324,7 +1420,7 @@ async function loadPreview() { |
| 1324 | 1420 | ? (nested as Record<string, unknown>) |
| 1325 | 1421 | : null |
| 1326 | 1422 | |
| 1327 | - /** 8.2 预览:labelLastEdited / labelId(兼容 data 嵌套、lastEdited、PascalCase) */ | |
| 1423 | + /** 8.2 预览:labelLastEdited / labelId(兼容 printLabelDisplayId、PascalCase、data 嵌套) */ | |
| 1328 | 1424 | const readLastEdited = (L: Record<string, unknown> | null): string => { |
| 1329 | 1425 | if (!L) return '' |
| 1330 | 1426 | const v = |
| ... | ... | @@ -1334,17 +1430,52 @@ async function loadPreview() { |
| 1334 | 1430 | L.LastEdited |
| 1335 | 1431 | return typeof v === 'string' && v.trim() ? v.trim() : '' |
| 1336 | 1432 | } |
| 1337 | - const readLabelId = (L: Record<string, unknown> | null): string => { | |
| 1433 | + const readPrintLabelDisplayId = (L: Record<string, unknown> | null): string => { | |
| 1338 | 1434 | if (!L) return '' |
| 1339 | - const v = L.labelId ?? L.LabelId | |
| 1340 | - return v != null && String(v).trim() !== '' ? String(v).trim() : '' | |
| 1435 | + const keys = [ | |
| 1436 | + 'printLabelDisplayId', | |
| 1437 | + 'PrintLabelDisplayId', | |
| 1438 | + 'labelDisplayId', | |
| 1439 | + 'LabelDisplayId', | |
| 1440 | + 'labelId', | |
| 1441 | + 'LabelId', | |
| 1442 | + ] | |
| 1443 | + for (const key of keys) { | |
| 1444 | + const v = L[key] | |
| 1445 | + if (v != null && String(v).trim() !== '') return String(v).trim() | |
| 1446 | + } | |
| 1447 | + return '' | |
| 1448 | + } | |
| 1449 | + const readTemplateName = (...sources: Array<unknown>): string => { | |
| 1450 | + for (const source of sources) { | |
| 1451 | + if (source == null || typeof source !== 'object' || Array.isArray(source)) continue | |
| 1452 | + const obj = source as Record<string, unknown> | |
| 1453 | + const direct = | |
| 1454 | + obj.name ?? | |
| 1455 | + obj.Name ?? | |
| 1456 | + obj.templateName ?? | |
| 1457 | + obj.TemplateName | |
| 1458 | + if (direct != null && String(direct).trim()) return String(direct).trim() | |
| 1459 | + const nestedTemplate = obj.template ?? obj.Template | |
| 1460 | + if (nestedTemplate != null && typeof nestedTemplate === 'object' && !Array.isArray(nestedTemplate)) { | |
| 1461 | + const t = nestedTemplate as Record<string, unknown> | |
| 1462 | + const nestedName = | |
| 1463 | + t.name ?? | |
| 1464 | + t.Name ?? | |
| 1465 | + t.templateName ?? | |
| 1466 | + t.TemplateName | |
| 1467 | + if (nestedName != null && String(nestedName).trim()) return String(nestedName).trim() | |
| 1468 | + } | |
| 1469 | + } | |
| 1470 | + return '' | |
| 1341 | 1471 | } |
| 1342 | 1472 | |
| 1343 | 1473 | const leStr = readLastEdited(root) || readLastEdited(inner) |
| 1344 | 1474 | lastEdited.value = leStr || '—' |
| 1345 | 1475 | |
| 1346 | - const lidStr = readLabelId(root) || readLabelId(inner) | |
| 1347 | - if (lidStr) labelIdDisplay.value = lidStr | |
| 1476 | + const displayIdStr = | |
| 1477 | + readPrintLabelDisplayId(root) || readPrintLabelDisplayId(inner) | |
| 1478 | + printLabelDisplayId.value = displayIdStr | |
| 1348 | 1479 | |
| 1349 | 1480 | const tmplPayload = |
| 1350 | 1481 | inner != null && |
| ... | ... | @@ -1360,6 +1491,10 @@ async function loadPreview() { |
| 1360 | 1491 | previewLoading.value = false |
| 1361 | 1492 | return |
| 1362 | 1493 | } |
| 1494 | + const templateNameFromApi = readTemplateName(inner, root, tmplRaw) | |
| 1495 | + if (templateNameFromApi) { | |
| 1496 | + templateName.value = templateNameFromApi | |
| 1497 | + } | |
| 1363 | 1498 | const lstRaw = |
| 1364 | 1499 | root.labelSizeText ?? |
| 1365 | 1500 | root.LabelSizeText ?? |
| ... | ... | @@ -1369,7 +1504,12 @@ async function loadPreview() { |
| 1369 | 1504 | if (labelSizeTextFromApi && labelSizeTextFromApi.trim()) { |
| 1370 | 1505 | templateSize.value = labelSizeTextFromApi.trim() |
| 1371 | 1506 | } |
| 1507 | + let tmplBase = tmplRaw | |
| 1508 | + if (labelSizeTextFromApi && labelSizeTextFromApi.trim()) { | |
| 1509 | + tmplBase = applyLabelSizeTextToTemplate(tmplBase, labelSizeTextFromApi) | |
| 1510 | + } | |
| 1372 | 1511 | const productDefaults = extractTemplateProductDefaultValuesFromPreviewPayload(raw) |
| 1512 | + previewProductDefaults.value = productDefaults | |
| 1373 | 1513 | const readLabelName = (L: Record<string, unknown> | null): string => { |
| 1374 | 1514 | if (!L) return '' |
| 1375 | 1515 | const v = L.labelName ?? L.LabelName |
| ... | ... | @@ -1379,19 +1519,12 @@ async function loadPreview() { |
| 1379 | 1519 | readLabelName(root) || |
| 1380 | 1520 | readLabelName(inner) || |
| 1381 | 1521 | '' |
| 1382 | - let tmplWithDefaults = | |
| 1383 | - Object.keys(productDefaults).length > 0 | |
| 1384 | - ? applyTemplateProductDefaultValuesToTemplate(tmplRaw, productDefaults) | |
| 1385 | - : tmplRaw | |
| 1386 | 1522 | const productCodeValue = extractProductCodeValueFromPreviewPayload(raw) |
| 1387 | 1523 | if (productCodeValue) { |
| 1388 | - tmplWithDefaults = applyProductCodeValueToTemplateScanElements( | |
| 1389 | - tmplWithDefaults, | |
| 1390 | - productCodeValue, | |
| 1391 | - ) | |
| 1524 | + tmplBase = applyProductCodeValueToTemplateScanElements(tmplBase, productCodeValue) | |
| 1392 | 1525 | } |
| 1393 | - /** 画布像素仅按接口 template 的 width / height / unit 换算(与 renderLabelPreviewCanvas.toCanvasPx 一致),不用 labelSizeText 覆盖以免单位被误判 */ | |
| 1394 | - let base = overlayProductNameOnPreviewTemplate(tmplWithDefaults, displayProductName.value) | |
| 1526 | + /** 仅存原始模板 + 默认值;日期/时间在 computeMergedPreviewTemplate 内按同一 baseTime 重算一次 */ | |
| 1527 | + let base = overlayProductNameOnPreviewTemplate(tmplBase, displayProductName.value) | |
| 1395 | 1528 | base = applyLabelBindingsToPreviewTemplate(base, { |
| 1396 | 1529 | labelName: previewLabelName.value, |
| 1397 | 1530 | labelTypeName: labelTypeName.value, |
| ... | ... | @@ -1455,6 +1588,11 @@ const handlePrint = async () => { |
| 1455 | 1588 | if (isPrinting.value || previewLoading.value || !systemTemplate.value) return |
| 1456 | 1589 | |
| 1457 | 1590 | preferBuiltinPrinterOnAioDevice() |
| 1591 | + btConnected.value = isPrinterReadySync() | |
| 1592 | + const summaryAfterPrefer = getCurrentPrinterSummary() | |
| 1593 | + if (summaryAfterPrefer.type === 'builtin') { | |
| 1594 | + btDeviceName.value = summaryAfterPrefer.driverName || 'Built-in' | |
| 1595 | + } | |
| 1458 | 1596 | |
| 1459 | 1597 | if (!isPrinterReadySync()) { |
| 1460 | 1598 | showNoPrinterModal.value = true |
| ... | ... | @@ -1483,7 +1621,9 @@ const handlePrint = async () => { |
| 1483 | 1621 | } |
| 1484 | 1622 | |
| 1485 | 1623 | try { |
| 1486 | - await ensureClassicReadyForPrint() | |
| 1624 | + if (getCurrentPrinterSummary().type === 'bluetooth') { | |
| 1625 | + await ensureClassicReadyForPrint() | |
| 1626 | + } | |
| 1487 | 1627 | } catch (e: any) { |
| 1488 | 1628 | const msg = e?.message ? String(e.message) : 'CLASSIC_NOT_READY' |
| 1489 | 1629 | uni.showModal({ |
| ... | ... | @@ -1515,6 +1655,7 @@ const handlePrint = async () => { |
| 1515 | 1655 | } |
| 1516 | 1656 | |
| 1517 | 1657 | isPrinting.value = true |
| 1658 | + startPrintRunDiagnostics() | |
| 1518 | 1659 | const printJobId = createPrintJobId() |
| 1519 | 1660 | let watchdog: ReturnType<typeof setTimeout> | null = null |
| 1520 | 1661 | let globalWatchdog: ReturnType<typeof setTimeout> | null = null |
| ... | ... | @@ -1535,13 +1676,14 @@ const handlePrint = async () => { |
| 1535 | 1676 | try { |
| 1536 | 1677 | lastShownPrintPct = -999 |
| 1537 | 1678 | uni.showLoading({ title: 'Rendering…', mask: true }) |
| 1538 | - /** 按 label-template-*.json 结构组装 template + printInputJson;出纸与 Test Print 相同:PNG → Bitmap → TSC → BLE */ | |
| 1539 | - const mergedForPrint = computeMergedPreviewTemplate() | |
| 1679 | + /** 出纸时刻为唯一基准:Prep / Use By 等同一次点击内共用当前时间 */ | |
| 1680 | + const printBaseTime = new Date() | |
| 1681 | + const mergedForPrint = computeMergedPreviewTemplate(printBaseTime) | |
| 1540 | 1682 | const tmplBase = mergedForPrint ?? systemTemplate.value |
| 1541 | 1683 | if (!tmplBase || !instance) { |
| 1542 | 1684 | throw new Error('No label to print.') |
| 1543 | 1685 | } |
| 1544 | - const tmpl = ensureTemplateHeightCoversElements(tmplBase) | |
| 1686 | + let tmpl = ensureTemplateHeightCoversElements(tmplBase) | |
| 1545 | 1687 | const phys = getTemplatePhysicalSizeMm(tmpl) |
| 1546 | 1688 | console.info( |
| 1547 | 1689 | '[preview] print physical size', |
| ... | ... | @@ -1556,18 +1698,63 @@ const handlePrint = async () => { |
| 1556 | 1698 | printFreeFieldValues.value |
| 1557 | 1699 | ) |
| 1558 | 1700 | printStage = 'payload-ready' |
| 1701 | + | |
| 1702 | + /** 模板含 Label ID 时先落库以分配当日序号,再带着真实序号出纸(与 Print Log 一致) */ | |
| 1703 | + let printLogAlreadySynced = false | |
| 1704 | + let printLogRequestBody: ReturnType<typeof buildUsAppLabelPrintRequestBody> = null | |
| 1705 | + const printClientRequestId = createPrintClientRequestId() | |
| 1706 | + if (templateIncludesLabelIdElement(tmpl)) { | |
| 1707 | + printStage = 'reserve-label-id' | |
| 1708 | + const persistTemplateDocEarly = JSON.parse( | |
| 1709 | + JSON.stringify(buildLabelPrintJobPayload(tmpl, printInputJson).template), | |
| 1710 | + ) as Record<string, unknown> | |
| 1711 | + const printInputSnapshotEarly: Record<string, unknown> = { | |
| 1712 | + ...printInputJson, | |
| 1713 | + ...persistTemplateDocEarly, | |
| 1714 | + } | |
| 1715 | + printLogRequestBody = buildUsAppLabelPrintRequestBody({ | |
| 1716 | + locationId: getCurrentStoreId(), | |
| 1717 | + labelCode: labelCode.value, | |
| 1718 | + productId: productId.value || undefined, | |
| 1719 | + printQuantity: printQty.value, | |
| 1720 | + mergedTemplate: printInputSnapshotEarly, | |
| 1721 | + clientRequestId: printClientRequestId, | |
| 1722 | + printerMac: getBluetoothConnection()?.deviceId || undefined, | |
| 1723 | + }) | |
| 1724 | + if (printLogRequestBody) { | |
| 1725 | + const printOut = await postUsAppLabelPrint(printLogRequestBody) | |
| 1726 | + const ids = | |
| 1727 | + printOut?.printLabelDisplayIds ?? | |
| 1728 | + (printOut as { PrintLabelDisplayIds?: string[] })?.PrintLabelDisplayIds ?? | |
| 1729 | + [] | |
| 1730 | + const firstId = Array.isArray(ids) && ids.length ? String(ids[0]).trim() : '' | |
| 1731 | + if (firstId) { | |
| 1732 | + printLabelDisplayId.value = firstId | |
| 1733 | + tmpl = ensureTemplateHeightCoversElements( | |
| 1734 | + applyLabelIdDisplayToTemplate(tmpl, firstId), | |
| 1735 | + ) | |
| 1736 | + } | |
| 1737 | + printLogAlreadySynced = true | |
| 1738 | + } | |
| 1739 | + } | |
| 1740 | + | |
| 1559 | 1741 | /** |
| 1560 | 1742 | * 经典蓝牙 + native-plugin(含 Virtual BT):走原生 printTemplate(JSON,快,与预览坐标一致)。 |
| 1561 | - * Virtual BT 不做 xScale/安全区收窄(易导致营养表错位、条码丢失);光栅仅作回退。 | |
| 1562 | - * 普通蓝牙(BLE 或 classic+JS socket):canPrintCurrentLabelViaNativeFastJob 为 false,走下方光栅/直发 TSC。 | |
| 1743 | + * Virtual BT / 内置 / 含日期时间或黑底白字:必须走 Canvas 光栅(与屏幕预览像素一致),禁止 JSON 直打。 | |
| 1563 | 1744 | */ |
| 1564 | - const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any) | |
| 1745 | + const tmplForNativeJob = normalizeTemplateForNativeFastJob(tmpl, printInputJson as any, printBaseTime) | |
| 1746 | + const printSummary = getCurrentPrinterSummary() | |
| 1747 | + const mustUseCanvasRasterPrint = | |
| 1748 | + printSummary.type === 'builtin' | |
| 1749 | + || templateRequiresCanvasStyleFidelity(tmpl) | |
| 1750 | + || templateHasDateTimeElements(tmpl) | |
| 1565 | 1751 | const currentDriver = getCurrentPrinterDriver() |
| 1566 | 1752 | const currentConn = getBluetoothConnection() |
| 1567 | 1753 | const isD320faxClassicNative = |
| 1568 | 1754 | currentDriver.key === 'd320fax' && currentConn?.deviceType === 'classic' |
| 1569 | 1755 | const useNativeTemplatePrint = |
| 1570 | - canPrintCurrentLabelViaNativeFastJob() | |
| 1756 | + !mustUseCanvasRasterPrint | |
| 1757 | + && canPrintCurrentLabelViaNativeFastJob() | |
| 1571 | 1758 | && isTemplateWithinNativeFastPrintBounds(tmpl) |
| 1572 | 1759 | && !templateHasUnsupportedNativeFastElements(tmplForNativeJob) |
| 1573 | 1760 | |
| ... | ... | @@ -1789,7 +1976,10 @@ const handlePrint = async () => { |
| 1789 | 1976 | const shouldUseDirectTemplate = |
| 1790 | 1977 | driver.protocol === 'tsc' && |
| 1791 | 1978 | templateHasQrDataForCommandPrint(tmpl) && |
| 1792 | - !templateHasUnsupportedElementsForCommandPrint(tmpl) | |
| 1979 | + !templateHasUnsupportedElementsForCommandPrint(tmpl) && | |
| 1980 | + !templateRequiresCanvasStyleFidelity(tmpl) && | |
| 1981 | + !templateHasDateTimeElements(tmpl) && | |
| 1982 | + !mustUseCanvasRasterPrint | |
| 1793 | 1983 | if (shouldUseDirectTemplate) { |
| 1794 | 1984 | const directJobMs = 240000 |
| 1795 | 1985 | if (globalWatchdog) { |
| ... | ... | @@ -1841,21 +2031,19 @@ const handlePrint = async () => { |
| 1841 | 2031 | } |
| 1842 | 2032 | const maxDots = |
| 1843 | 2033 | rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) |
| 1844 | - const escUseContentH = rasterDriver.protocol === 'esc' | |
| 1845 | - const contentHpx = escUseContentH ? templateContentHeightPx(tmpl) : undefined | |
| 1846 | - const layout = getLabelPrintRasterLayout( | |
| 1847 | - tmpl, | |
| 1848 | - maxDots, | |
| 1849 | - rasterDriver.imageDpi || 203, | |
| 1850 | - contentHpx != null ? { contentHeightPx: contentHpx } : undefined, | |
| 1851 | - ) | |
| 2034 | + const printDpi = rasterDriver.imageDpi || 203 | |
| 2035 | + const layout = getLabelPrintRasterLayout(tmpl, maxDots, printDpi) | |
| 2036 | + const layoutMm = rasterDotsToMillimeters(layout.outW, layout.outH, printDpi) | |
| 1852 | 2037 | |
| 1853 | - canvasCssW.value = layout.outW | |
| 1854 | - canvasCssH.value = layout.outH | |
| 1855 | - await nextTick() | |
| 1856 | - await new Promise<void>((r) => setTimeout(r, 50)) | |
| 2038 | + await applyPrintCanvasLayout(layout) | |
| 2039 | + await settleAfterLabelCanvasResize() | |
| 1857 | 2040 | |
| 1858 | 2041 | const useCanvasRasterPath = shouldRasterPrintViaCanvasImageData() |
| 2042 | + printRunDiag('preview_raster_path', { | |
| 2043 | + useCanvas: useCanvasRasterPath, | |
| 2044 | + deviceType: getBluetoothConnection()?.deviceType || '-', | |
| 2045 | + driver: driver.key, | |
| 2046 | + }) | |
| 1859 | 2047 | let tmpPath = '' |
| 1860 | 2048 | if (useCanvasRasterPath) { |
| 1861 | 2049 | try { |
| ... | ... | @@ -1915,8 +2103,10 @@ const handlePrint = async () => { |
| 1915 | 2103 | clearTopRasterRows: 0, |
| 1916 | 2104 | targetWidthDots: layout.outW, |
| 1917 | 2105 | targetHeightDots: layout.outH, |
| 1918 | - useContentHeight: true, | |
| 2106 | + useContentHeight: false, | |
| 1919 | 2107 | cutBetweenCopies: true, |
| 2108 | + widthMm: layoutMm.widthMm, | |
| 2109 | + heightMm: layoutMm.heightMm, | |
| 1920 | 2110 | }, |
| 1921 | 2111 | (percent) => { |
| 1922 | 2112 | if (!isPrintJobActive(printJobId)) return |
| ... | ... | @@ -1933,6 +2123,8 @@ const handlePrint = async () => { |
| 1933 | 2123 | targetWidthDots: layout.outW, |
| 1934 | 2124 | targetHeightDots: layout.outH, |
| 1935 | 2125 | labelRasterFixedHeight: true, |
| 2126 | + widthMm: layoutMm.widthMm, | |
| 2127 | + heightMm: layoutMm.heightMm, | |
| 1936 | 2128 | }, |
| 1937 | 2129 | (percent) => { |
| 1938 | 2130 | if (!isPrintJobActive(printJobId)) return |
| ... | ... | @@ -1948,7 +2140,11 @@ const handlePrint = async () => { |
| 1948 | 2140 | /* 内置已处理完本分支,跳过下方 PNG 光栅 */ |
| 1949 | 2141 | tmpPath = '__builtin_canvas_done__' |
| 1950 | 2142 | } catch (e) { |
| 1951 | - console.warn('[preview] builtin canvasGetImageData failed, fallback PNG raster', e) | |
| 2143 | + console.warn('[preview] canvasGetImageData raster failed', e) | |
| 2144 | + // BLE / 内置:PNG→Bitmap 会 RASTER_EMPTY_IMAGE,禁止回退 PNG。 | |
| 2145 | + if (useCanvasRasterPath) { | |
| 2146 | + throw e | |
| 2147 | + } | |
| 1952 | 2148 | tmpPath = '' |
| 1953 | 2149 | } |
| 1954 | 2150 | } |
| ... | ... | @@ -2014,8 +2210,10 @@ const handlePrint = async () => { |
| 2014 | 2210 | clearTopRasterRows: 0, |
| 2015 | 2211 | targetWidthDots: layout.outW, |
| 2016 | 2212 | targetHeightDots: layout.outH, |
| 2017 | - useContentHeight: true, | |
| 2213 | + useContentHeight: false, | |
| 2018 | 2214 | cutBetweenCopies: true, |
| 2215 | + widthMm: layoutMm.widthMm, | |
| 2216 | + heightMm: layoutMm.heightMm, | |
| 2019 | 2217 | }, |
| 2020 | 2218 | (percent) => { |
| 2021 | 2219 | if (!isPrintJobActive(printJobId)) return |
| ... | ... | @@ -2031,6 +2229,8 @@ const handlePrint = async () => { |
| 2031 | 2229 | targetWidthDots: layout.outW, |
| 2032 | 2230 | targetHeightDots: layout.outH, |
| 2033 | 2231 | labelRasterFixedHeight: true, |
| 2232 | + widthMm: layoutMm.widthMm, | |
| 2233 | + heightMm: layoutMm.heightMm, | |
| 2034 | 2234 | }, |
| 2035 | 2235 | (percent) => { |
| 2036 | 2236 | if (!isPrintJobActive(printJobId)) return |
| ... | ... | @@ -2047,10 +2247,10 @@ const handlePrint = async () => { |
| 2047 | 2247 | |
| 2048 | 2248 | /** 接口 9:仅本页业务标签出纸后落库(打印机设置/蓝牙 Test Print 不会执行此段) */ |
| 2049 | 2249 | let printLogSyncFailed = false |
| 2050 | - let printLogRequestBody: ReturnType<typeof buildUsAppLabelPrintRequestBody> = null | |
| 2051 | - if (!isPrintJobActive(printJobId)) { | |
| 2052 | - console.warn('[preview] skip post-print API/preview refresh (stale job id)') | |
| 2053 | - } else try { | |
| 2250 | + if (!printLogAlreadySynced) { | |
| 2251 | + printLogRequestBody = null | |
| 2252 | + } | |
| 2253 | + if (!printLogAlreadySynced && isPrintJobActive(printJobId)) try { | |
| 2054 | 2254 | const bt = getBluetoothConnection() |
| 2055 | 2255 | /** |
| 2056 | 2256 | * 接口 9 的 `printInputJson`:整份合并模板快照(与出纸 labelPrintJobPayload.template 同源)+ |
| ... | ... | @@ -2070,7 +2270,7 @@ const handlePrint = async () => { |
| 2070 | 2270 | productId: productId.value || undefined, |
| 2071 | 2271 | printQuantity: printQty.value, |
| 2072 | 2272 | mergedTemplate: printInputSnapshotForApi, |
| 2073 | - clientRequestId: createPrintClientRequestId(), | |
| 2273 | + clientRequestId: printClientRequestId, | |
| 2074 | 2274 | printerMac: bt?.deviceId || undefined, |
| 2075 | 2275 | }) |
| 2076 | 2276 | if (printLogRequestBody) { |
| ... | ... | @@ -2185,6 +2385,7 @@ const handlePrint = async () => { |
| 2185 | 2385 | }) |
| 2186 | 2386 | } |
| 2187 | 2387 | } finally { |
| 2388 | + persistPrintRunDiagnostics() | |
| 2188 | 2389 | if (!isPrintJobActive(printJobId)) return |
| 2189 | 2390 | try { |
| 2190 | 2391 | uni.hideLoading() |
| ... | ... | @@ -2254,7 +2455,7 @@ const handlePrint = async () => { |
| 2254 | 2455 | |
| 2255 | 2456 | .content { |
| 2256 | 2457 | padding: 32rpx; |
| 2257 | - padding-bottom: 300rpx; | |
| 2458 | + padding-bottom: 260rpx; | |
| 2258 | 2459 | box-sizing: border-box; |
| 2259 | 2460 | overflow-x: hidden; |
| 2260 | 2461 | overflow-x: clip; |
| ... | ... | @@ -2273,92 +2474,15 @@ const handlePrint = async () => { |
| 2273 | 2474 | color: #6b7280; |
| 2274 | 2475 | } |
| 2275 | 2476 | |
| 2276 | -.food-card { | |
| 2277 | - background: #fff; | |
| 2278 | - padding: 28rpx; | |
| 2279 | - border-radius: 20rpx; | |
| 2280 | - margin-bottom: 24rpx; | |
| 2477 | +.qty-control, | |
| 2478 | +.bottom-qty-control { | |
| 2281 | 2479 | display: flex; |
| 2282 | 2480 | align-items: center; |
| 2283 | - justify-content: space-between; | |
| 2284 | - gap: 24rpx; | |
| 2285 | - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); | |
| 2286 | -} | |
| 2287 | - | |
| 2288 | -.food-info { | |
| 2289 | - flex: 1; | |
| 2290 | - min-width: 0; | |
| 2291 | -} | |
| 2292 | - | |
| 2293 | -.food-name { | |
| 2294 | - font-size: 32rpx; | |
| 2295 | - font-weight: 600; | |
| 2296 | - color: #111827; | |
| 2297 | - display: block; | |
| 2298 | - margin-bottom: 4rpx; | |
| 2299 | -} | |
| 2300 | - | |
| 2301 | -.food-cat { | |
| 2302 | - font-size: 26rpx; | |
| 2303 | - color: #6b7280; | |
| 2304 | - display: block; | |
| 2305 | -} | |
| 2306 | - | |
| 2307 | -.food-label-type { | |
| 2308 | - display: flex; | |
| 2309 | - align-items: center; | |
| 2310 | - gap: 8rpx; | |
| 2311 | - margin-top: 12rpx; | |
| 2312 | -} | |
| 2313 | - | |
| 2314 | -.food-label-type-text { | |
| 2315 | - font-size: 24rpx; | |
| 2316 | - color: var(--theme-primary); | |
| 2317 | - font-weight: 500; | |
| 2318 | -} | |
| 2319 | - | |
| 2320 | -.food-template { | |
| 2321 | 2481 | flex-shrink: 0; |
| 2322 | - text-align: center; | |
| 2323 | - background: #f3f4f6; | |
| 2324 | - padding: 16rpx 20rpx; | |
| 2325 | - border-radius: 14rpx; | |
| 2326 | -} | |
| 2327 | - | |
| 2328 | -.template-size { | |
| 2329 | - font-size: 28rpx; | |
| 2330 | - font-weight: 700; | |
| 2331 | - color: #111827; | |
| 2332 | - display: block; | |
| 2333 | 2482 | } |
| 2334 | 2483 | |
| 2335 | -.template-name { | |
| 2336 | - font-size: 22rpx; | |
| 2337 | - color: #6b7280; | |
| 2338 | - display: block; | |
| 2339 | - margin-top: 4rpx; | |
| 2340 | -} | |
| 2341 | - | |
| 2342 | -.qty-card { | |
| 2343 | - display: flex; | |
| 2344 | - align-items: center; | |
| 2345 | - justify-content: space-between; | |
| 2346 | - background: #fff; | |
| 2347 | - padding: 24rpx 28rpx; | |
| 2348 | - border-radius: 20rpx; | |
| 2349 | - margin-bottom: 32rpx; | |
| 2350 | - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); | |
| 2351 | -} | |
| 2352 | - | |
| 2353 | -.qty-label { | |
| 2354 | - font-size: 30rpx; | |
| 2355 | - font-weight: 600; | |
| 2356 | - color: #111827; | |
| 2357 | -} | |
| 2358 | - | |
| 2359 | -.qty-control { | |
| 2360 | - display: flex; | |
| 2361 | - align-items: center; | |
| 2484 | +.bottom-qty-control { | |
| 2485 | + gap: 10rpx; | |
| 2362 | 2486 | } |
| 2363 | 2487 | |
| 2364 | 2488 | .qty-btn { |
| ... | ... | @@ -2369,12 +2493,27 @@ const handlePrint = async () => { |
| 2369 | 2493 | display: flex; |
| 2370 | 2494 | align-items: center; |
| 2371 | 2495 | justify-content: center; |
| 2496 | + flex-shrink: 0; | |
| 2372 | 2497 | } |
| 2373 | 2498 | |
| 2374 | -.qty-btn:active { | |
| 2499 | +.bottom-qty-control .qty-btn { | |
| 2500 | + width: 72rpx; | |
| 2501 | + height: 72rpx; | |
| 2502 | + border-radius: 50%; | |
| 2503 | +} | |
| 2504 | + | |
| 2505 | +.bottom-qty-control .qty-btn-minus { | |
| 2375 | 2506 | background: #e5e7eb; |
| 2376 | 2507 | } |
| 2377 | 2508 | |
| 2509 | +.bottom-qty-control .qty-btn-plus { | |
| 2510 | + background: #111827; | |
| 2511 | +} | |
| 2512 | + | |
| 2513 | +.qty-btn:active { | |
| 2514 | + opacity: 0.85; | |
| 2515 | +} | |
| 2516 | + | |
| 2378 | 2517 | .qty-btn.disabled { |
| 2379 | 2518 | opacity: 0.35; |
| 2380 | 2519 | } |
| ... | ... | @@ -2387,6 +2526,12 @@ const handlePrint = async () => { |
| 2387 | 2526 | color: #111827; |
| 2388 | 2527 | } |
| 2389 | 2528 | |
| 2529 | +.bottom-qty-control .qty-value { | |
| 2530 | + width: 56rpx; | |
| 2531 | + font-size: 34rpx; | |
| 2532 | + font-variant-numeric: tabular-nums; | |
| 2533 | +} | |
| 2534 | + | |
| 2390 | 2535 | .print-options-card { |
| 2391 | 2536 | background: #fff; |
| 2392 | 2537 | border-radius: 20rpx; |
| ... | ... | @@ -2411,6 +2556,15 @@ const handlePrint = async () => { |
| 2411 | 2556 | margin-bottom: 24rpx; |
| 2412 | 2557 | } |
| 2413 | 2558 | |
| 2559 | +.print-input-prompt { | |
| 2560 | + font-size: 28rpx; | |
| 2561 | + font-weight: 600; | |
| 2562 | + color: var(--theme-primary, #1f3a8a); | |
| 2563 | + line-height: 1.45; | |
| 2564 | + display: block; | |
| 2565 | + margin-bottom: 24rpx; | |
| 2566 | +} | |
| 2567 | + | |
| 2414 | 2568 | .print-option-block { |
| 2415 | 2569 | margin-bottom: 28rpx; |
| 2416 | 2570 | } |
| ... | ... | @@ -2562,6 +2716,41 @@ const handlePrint = async () => { |
| 2562 | 2716 | margin-bottom: 20rpx; |
| 2563 | 2717 | } |
| 2564 | 2718 | |
| 2719 | +.section-title-first { | |
| 2720 | + margin-top: 0; | |
| 2721 | +} | |
| 2722 | + | |
| 2723 | +.meta-footer-scroll { | |
| 2724 | + width: 100%; | |
| 2725 | + margin-bottom: 24rpx; | |
| 2726 | + white-space: nowrap; | |
| 2727 | +} | |
| 2728 | + | |
| 2729 | +.meta-footer-row { | |
| 2730 | + display: inline-flex; | |
| 2731 | + flex-direction: row; | |
| 2732 | + align-items: stretch; | |
| 2733 | + gap: 10rpx; | |
| 2734 | + padding-bottom: 4rpx; | |
| 2735 | +} | |
| 2736 | + | |
| 2737 | +.meta-item { | |
| 2738 | + flex: 0 0 auto; | |
| 2739 | + min-width: 168rpx; | |
| 2740 | + max-width: 280rpx; | |
| 2741 | + background: #fff; | |
| 2742 | + padding: 16rpx 20rpx; | |
| 2743 | + border-radius: 16rpx; | |
| 2744 | + box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); | |
| 2745 | + box-sizing: border-box; | |
| 2746 | +} | |
| 2747 | + | |
| 2748 | +.meta-item .info-value { | |
| 2749 | + overflow: hidden; | |
| 2750 | + text-overflow: ellipsis; | |
| 2751 | + white-space: nowrap; | |
| 2752 | +} | |
| 2753 | + | |
| 2565 | 2754 | .label-card { |
| 2566 | 2755 | background: #fff; |
| 2567 | 2756 | border-radius: 20rpx; |
| ... | ... | @@ -2608,24 +2797,6 @@ const handlePrint = async () => { |
| 2608 | 2797 | color: #9ca3af; |
| 2609 | 2798 | } |
| 2610 | 2799 | |
| 2611 | -.info-row { | |
| 2612 | - display: flex; | |
| 2613 | - margin-bottom: 24rpx; | |
| 2614 | -} | |
| 2615 | - | |
| 2616 | -.info-item { | |
| 2617 | - flex: 1; | |
| 2618 | - min-width: 0; | |
| 2619 | - background: #fff; | |
| 2620 | - padding: 20rpx 24rpx; | |
| 2621 | - border-radius: 16rpx; | |
| 2622 | - box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04); | |
| 2623 | -} | |
| 2624 | - | |
| 2625 | -.info-item + .info-item { | |
| 2626 | - margin-left: 10rpx; | |
| 2627 | -} | |
| 2628 | - | |
| 2629 | 2800 | .info-label { |
| 2630 | 2801 | font-size: 22rpx; |
| 2631 | 2802 | color: #9ca3af; |
| ... | ... | @@ -2640,23 +2811,6 @@ const handlePrint = async () => { |
| 2640 | 2811 | display: block; |
| 2641 | 2812 | } |
| 2642 | 2813 | |
| 2643 | -.note-card { | |
| 2644 | - display: flex; | |
| 2645 | - align-items: flex-start; | |
| 2646 | - gap: 16rpx; | |
| 2647 | - padding: 24rpx 28rpx; | |
| 2648 | - background: #eff6ff; | |
| 2649 | - border: 1rpx solid #bfdbfe; | |
| 2650 | - border-radius: 16rpx; | |
| 2651 | -} | |
| 2652 | - | |
| 2653 | -.note-text { | |
| 2654 | - flex: 1; | |
| 2655 | - font-size: 26rpx; | |
| 2656 | - color: #1e40af; | |
| 2657 | - line-height: 1.5; | |
| 2658 | -} | |
| 2659 | - | |
| 2660 | 2814 | .bottom-bar { |
| 2661 | 2815 | position: fixed; |
| 2662 | 2816 | bottom: 0; |
| ... | ... | @@ -2710,13 +2864,13 @@ const handlePrint = async () => { |
| 2710 | 2864 | |
| 2711 | 2865 | .bottom-actions { |
| 2712 | 2866 | display: flex; |
| 2713 | - align-items: stretch; | |
| 2714 | - gap: 24rpx; | |
| 2867 | + align-items: center; | |
| 2868 | + gap: 16rpx; | |
| 2715 | 2869 | } |
| 2716 | 2870 | |
| 2717 | 2871 | .btn-preview-sq { |
| 2718 | - width: 100rpx; | |
| 2719 | - height: 100rpx; | |
| 2872 | + width: 88rpx; | |
| 2873 | + height: 88rpx; | |
| 2720 | 2874 | flex-shrink: 0; |
| 2721 | 2875 | background: #fff; |
| 2722 | 2876 | border: 2rpx solid #d1d5db; |
| ... | ... | @@ -2733,7 +2887,7 @@ const handlePrint = async () => { |
| 2733 | 2887 | .print-btn { |
| 2734 | 2888 | flex: 1; |
| 2735 | 2889 | min-width: 0; |
| 2736 | - height: 100rpx; | |
| 2890 | + height: 88rpx; | |
| 2737 | 2891 | background: var(--theme-primary); |
| 2738 | 2892 | border-radius: 16rpx; |
| 2739 | 2893 | display: flex; | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/more/print-log.vue
| ... | ... | @@ -32,6 +32,26 @@ |
| 32 | 32 | </view> |
| 33 | 33 | </view> |
| 34 | 34 | |
| 35 | + <view class="date-filter-bar"> | |
| 36 | + <view class="date-field"> | |
| 37 | + <AppDatePicker | |
| 38 | + :model-value="datePickerModelValue" | |
| 39 | + :max="todayYmd" | |
| 40 | + placeholder="ALL" | |
| 41 | + dialog-title="Print date" | |
| 42 | + @update:model-value="onDateConfirm" | |
| 43 | + @cancel="onDateCancel" | |
| 44 | + /> | |
| 45 | + </view> | |
| 46 | + <view | |
| 47 | + class="today-btn" | |
| 48 | + :class="{ active: isTodayFilter }" | |
| 49 | + @click="selectToday" | |
| 50 | + > | |
| 51 | + <text class="today-btn-text">Today</text> | |
| 52 | + </view> | |
| 53 | + </view> | |
| 54 | + | |
| 35 | 55 | <scroll-view |
| 36 | 56 | class="content" |
| 37 | 57 | scroll-y |
| ... | ... | @@ -42,7 +62,7 @@ |
| 42 | 62 | <text class="state-text">Loading…</text> |
| 43 | 63 | </view> |
| 44 | 64 | <view v-else-if="!loading && !items.length" class="state-block"> |
| 45 | - <text class="state-text">No print records</text> | |
| 65 | + <text class="state-text">{{ emptyStateText }}</text> | |
| 46 | 66 | </view> |
| 47 | 67 | |
| 48 | 68 | <!-- 卡片视图 --> |
| ... | ... | @@ -54,7 +74,6 @@ |
| 54 | 74 | > |
| 55 | 75 | <view class="card-header"> |
| 56 | 76 | <text class="product-name">{{ displayField(row.productName) }}</text> |
| 57 | - <text class="label-id">{{ shortRef(row) }}</text> | |
| 58 | 77 | </view> |
| 59 | 78 | <view class="card-tags"> |
| 60 | 79 | <text class="tag">{{ tagLabelSize(row) }}</text> |
| ... | ... | @@ -62,15 +81,15 @@ |
| 62 | 81 | </view> |
| 63 | 82 | <view class="card-details"> |
| 64 | 83 | <view class="detail-row"> |
| 65 | - <AppIcon name="clock" size="sm" color="gray" /> | |
| 84 | + <AppIcon name="clock" size="xs" color="gray" /> | |
| 66 | 85 | <text class="detail-text">{{ row.printedAt }}</text> |
| 67 | 86 | </view> |
| 68 | 87 | <view class="detail-row"> |
| 69 | - <AppIcon name="user" size="sm" color="gray" /> | |
| 88 | + <AppIcon name="user" size="xs" color="gray" /> | |
| 70 | 89 | <text class="detail-text">{{ displayField(row.operatorName) }}</text> |
| 71 | 90 | </view> |
| 72 | 91 | <view class="detail-row"> |
| 73 | - <AppIcon name="mapPin" size="sm" color="gray" /> | |
| 92 | + <AppIcon name="mapPin" size="xs" color="gray" /> | |
| 74 | 93 | <text class="detail-text">{{ displayField(row.locationName) }}</text> |
| 75 | 94 | </view> |
| 76 | 95 | </view> |
| ... | ... | @@ -93,24 +112,24 @@ |
| 93 | 112 | <view v-else class="log-table-wrap"> |
| 94 | 113 | <view class="log-table"> |
| 95 | 114 | <view class="log-table-header"> |
| 96 | - <text class="th th-product">Product</text> | |
| 97 | - <text class="th th-id">Ref</text> | |
| 98 | - <text class="th th-size">Label size</text> | |
| 99 | - <text class="th th-type">Type</text> | |
| 100 | - <text class="th th-printed">Printed At</text> | |
| 101 | - <text class="th th-action">Action</text> | |
| 115 | + <text class="th th-wrap th-product">Product</text> | |
| 116 | + <text class="th th-wrap th-size">Label size</text> | |
| 117 | + <text class="th th-wrap th-label-id">Label ID</text> | |
| 118 | + <text class="th th-wrap th-type">Type</text> | |
| 119 | + <text class="th th-fixed th-printed">Printed At</text> | |
| 120 | + <text class="th th-fixed th-action">Action</text> | |
| 102 | 121 | </view> |
| 103 | 122 | <view |
| 104 | 123 | v-for="row in items" |
| 105 | 124 | :key="row.taskId + '-' + row.copyIndex" |
| 106 | 125 | class="log-table-row" |
| 107 | 126 | > |
| 108 | - <text class="td td-product">{{ displayField(row.productName) }}</text> | |
| 109 | - <text class="td td-id">{{ shortRef(row) }}</text> | |
| 110 | - <text class="td td-size">{{ tagLabelSize(row) }}</text> | |
| 111 | - <text class="td td-type">{{ tagTypeName(row) }}</text> | |
| 112 | - <text class="td td-printed">{{ row.printedAt }}</text> | |
| 113 | - <view class="td td-action"> | |
| 127 | + <text class="td td-wrap td-product">{{ displayField(row.productName) }}</text> | |
| 128 | + <text class="td td-wrap td-size">{{ tagLabelSize(row) }}</text> | |
| 129 | + <text class="td td-wrap td-label-id">{{ tagLabelId(row) }}</text> | |
| 130 | + <text class="td td-wrap td-type">{{ tagTypeName(row) }}</text> | |
| 131 | + <text class="td td-fixed td-printed">{{ row.printedAt }}</text> | |
| 132 | + <view class="td td-fixed td-action"> | |
| 114 | 133 | <view class="reprint-btn-sm" @click="handleReprint(row)"> |
| 115 | 134 | <AppIcon name="printer" size="sm" color="white" /> |
| 116 | 135 | </view> |
| ... | ... | @@ -138,9 +157,10 @@ |
| 138 | 157 | </template> |
| 139 | 158 | |
| 140 | 159 | <script setup lang="ts"> |
| 141 | -import { ref, getCurrentInstance, nextTick } from 'vue' | |
| 160 | +import { ref, computed, getCurrentInstance, nextTick } from 'vue' | |
| 142 | 161 | import { onShow } from '@dcloudio/uni-app' |
| 143 | 162 | import AppIcon from '../../components/AppIcon.vue' |
| 163 | +import AppDatePicker from '../../components/AppDatePicker.vue' | |
| 144 | 164 | import SideMenu from '../../components/SideMenu.vue' |
| 145 | 165 | import LocationPicker from '../../components/LocationPicker.vue' |
| 146 | 166 | import { getStatusBarHeight } from '../../utils/statusBar' |
| ... | ... | @@ -163,6 +183,61 @@ const statusBarHeight = getStatusBarHeight() |
| 163 | 183 | const isMenuOpen = ref(false) |
| 164 | 184 | const viewMode = ref<'card' | 'list'>('card') |
| 165 | 185 | |
| 186 | +function formatYmd (d: Date): string { | |
| 187 | + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` | |
| 188 | +} | |
| 189 | + | |
| 190 | +const todayYmd = formatYmd(new Date()) | |
| 191 | +/** | |
| 192 | + * null = 不传 printDateDay,后端不按日过滤(6-18 返回该门店全部记录); | |
| 193 | + * yyyy-MM-dd = 确认后传 printDateDay 筛选该自然日。 | |
| 194 | + */ | |
| 195 | +const filterDateYmd = ref<string | null>(todayYmd) | |
| 196 | + | |
| 197 | +const isTodayFilter = computed(() => filterDateYmd.value === todayYmd) | |
| 198 | +const isAllDatesFilter = computed(() => filterDateYmd.value === null) | |
| 199 | +/** 查全部时不传日期,控件显示 placeholder「ALL」 */ | |
| 200 | +const datePickerModelValue = computed(() => filterDateYmd.value ?? '') | |
| 201 | + | |
| 202 | +function toPrintDatePayload (ymd: string): string { | |
| 203 | + return (ymd || '').trim() | |
| 204 | +} | |
| 205 | + | |
| 206 | +function formatMdY (ymd: string): string { | |
| 207 | + const [y, m, d] = ymd.split('-') | |
| 208 | + if (!y || !m || !d) return ymd | |
| 209 | + return `${m}/${d}/${y}` | |
| 210 | +} | |
| 211 | + | |
| 212 | +/** 6-18:未传 printDateDay 时不带该字段;选定日期时传 yyyy-MM-dd */ | |
| 213 | +function resolvePrintDateDayForApi (): string | undefined { | |
| 214 | + if (filterDateYmd.value === null) return undefined | |
| 215 | + const payload = toPrintDatePayload(filterDateYmd.value) | |
| 216 | + return payload || undefined | |
| 217 | +} | |
| 218 | + | |
| 219 | +const emptyStateText = computed(() => { | |
| 220 | + if (isAllDatesFilter.value) return 'No print records' | |
| 221 | + const ymd = filterDateYmd.value ?? todayYmd | |
| 222 | + return `No print records for ${formatMdY(ymd)}` | |
| 223 | +}) | |
| 224 | + | |
| 225 | +function onDateConfirm (value: string) { | |
| 226 | + const next = (value || '').trim() | |
| 227 | + filterDateYmd.value = next || todayYmd | |
| 228 | + loadPage(true) | |
| 229 | +} | |
| 230 | + | |
| 231 | +function onDateCancel () { | |
| 232 | + filterDateYmd.value = null | |
| 233 | + loadPage(true) | |
| 234 | +} | |
| 235 | + | |
| 236 | +function selectToday () { | |
| 237 | + filterDateYmd.value = todayYmd | |
| 238 | + loadPage(true) | |
| 239 | +} | |
| 240 | + | |
| 166 | 241 | const reprintCanvasW = ref(400) |
| 167 | 242 | const reprintCanvasH = ref(400) |
| 168 | 243 | const contentHeight = ref(0) |
| ... | ... | @@ -186,10 +261,12 @@ const computeContentHeight = async () => { |
| 186 | 261 | const q = uni.createSelectorQuery().in(inst as any) |
| 187 | 262 | q.select('.header-hero').boundingClientRect() |
| 188 | 263 | q.select('.view-toggle').boundingClientRect() |
| 264 | + q.select('.date-filter-bar').boundingClientRect() | |
| 189 | 265 | q.exec((rects: any[]) => { |
| 190 | 266 | const headerH = Number(rects?.[0]?.height || 0) |
| 191 | 267 | const toggleH = Number(rects?.[1]?.height || 0) |
| 192 | - const safe = Math.max(260, Math.round(windowHeight - headerH - toggleH)) | |
| 268 | + const dateBarH = Number(rects?.[2]?.height || 0) | |
| 269 | + const safe = Math.max(260, Math.round(windowHeight - headerH - toggleH - dateBarH)) | |
| 193 | 270 | contentHeight.value = safe |
| 194 | 271 | }) |
| 195 | 272 | } |
| ... | ... | @@ -213,12 +290,6 @@ function displayField (value: string | null | undefined): string { |
| 213 | 290 | return formatDisplayText(value, 'None') |
| 214 | 291 | } |
| 215 | 292 | |
| 216 | -function shortRef (row: PrintLogItemDto): string { | |
| 217 | - const b = String(row.batchId || row.taskId || '').trim() | |
| 218 | - if (b.length > 14) return `${b.slice(0, 8)}…` | |
| 219 | - return b || row.labelCode || '—' | |
| 220 | -} | |
| 221 | - | |
| 222 | 293 | /** 红框左:labelSizeText(兼容 PascalCase) */ |
| 223 | 294 | function tagLabelSize (row: PrintLogItemDto): string { |
| 224 | 295 | const raw = row.labelSizeText ?? (row as unknown as { LabelSizeText?: string }).LabelSizeText |
| ... | ... | @@ -226,6 +297,13 @@ function tagLabelSize (row: PrintLogItemDto): string { |
| 226 | 297 | return formatDisplayText(s, 'None') |
| 227 | 298 | } |
| 228 | 299 | |
| 300 | +/** 列表 Label ID 列:labelId(兼容 PascalCase) */ | |
| 301 | +function tagLabelId (row: PrintLogItemDto): string { | |
| 302 | + const raw = row.labelId ?? (row as unknown as { LabelId?: string }).LabelId | |
| 303 | + const s = String(raw ?? '').trim() | |
| 304 | + return formatDisplayText(s, 'None') | |
| 305 | +} | |
| 306 | + | |
| 229 | 307 | /** 红框右:typeName(兼容 PascalCase) */ |
| 230 | 308 | function tagTypeName (row: PrintLogItemDto): string { |
| 231 | 309 | const raw = row.typeName ?? (row as unknown as { TypeName?: string }).TypeName |
| ... | ... | @@ -253,13 +331,15 @@ async function loadPage (reset: boolean) { |
| 253 | 331 | locationId, |
| 254 | 332 | skipCount: p, |
| 255 | 333 | maxResultCount: pageSize, |
| 334 | + printDateDay: resolvePrintDateDayForApi(), | |
| 256 | 335 | }) |
| 257 | 336 | if (reset) { |
| 258 | 337 | items.value = res.items |
| 259 | 338 | } else { |
| 260 | 339 | items.value = [...items.value, ...res.items] |
| 261 | 340 | } |
| 262 | - hasMore.value = items.value.length < res.totalCount | |
| 341 | + const totalCount = Number(res.totalCount ?? 0) | |
| 342 | + hasMore.value = items.value.length < totalCount | |
| 263 | 343 | if (res.items.length > 0) { |
| 264 | 344 | pageIndex.value = p + 1 |
| 265 | 345 | } |
| ... | ... | @@ -432,21 +512,10 @@ const goBack = () => { |
| 432 | 512 | font-size: 34rpx; |
| 433 | 513 | font-weight: 700; |
| 434 | 514 | color: #111827; |
| 435 | - flex: 1; | |
| 436 | 515 | line-height: 1.3; |
| 437 | 516 | letter-spacing: -0.02em; |
| 438 | 517 | } |
| 439 | 518 | |
| 440 | -.label-id { | |
| 441 | - font-size: 22rpx; | |
| 442 | - color: #9ca3af; | |
| 443 | - flex-shrink: 0; | |
| 444 | - margin-left: 16rpx; | |
| 445 | - padding: 6rpx 12rpx; | |
| 446 | - background: #f3f4f6; | |
| 447 | - border-radius: 8rpx; | |
| 448 | -} | |
| 449 | - | |
| 450 | 519 | .card-tags { |
| 451 | 520 | display: flex; |
| 452 | 521 | flex-wrap: wrap; |
| ... | ... | @@ -466,7 +535,7 @@ const goBack = () => { |
| 466 | 535 | .card-details { |
| 467 | 536 | display: flex; |
| 468 | 537 | flex-direction: column; |
| 469 | - gap: 16rpx; | |
| 538 | + gap: 20rpx; | |
| 470 | 539 | padding: 24rpx 28rpx; |
| 471 | 540 | background: #fafbfc; |
| 472 | 541 | border-top: 1rpx solid #e5e7eb; |
| ... | ... | @@ -475,13 +544,15 @@ const goBack = () => { |
| 475 | 544 | .detail-row { |
| 476 | 545 | display: flex; |
| 477 | 546 | align-items: center; |
| 478 | - gap: 12rpx; | |
| 547 | + gap: 14rpx; | |
| 548 | + min-height: 36rpx; | |
| 479 | 549 | } |
| 480 | 550 | |
| 481 | 551 | .detail-text { |
| 482 | - font-size: 28rpx; | |
| 552 | + font-size: 26rpx; | |
| 483 | 553 | color: #4b5563; |
| 484 | - line-height: 1.4; | |
| 554 | + line-height: 1.5; | |
| 555 | + flex: 1; | |
| 485 | 556 | } |
| 486 | 557 | |
| 487 | 558 | .card-footer { |
| ... | ... | @@ -535,6 +606,47 @@ const goBack = () => { |
| 535 | 606 | background: linear-gradient(135deg, #1F3A8A, #1447E6); |
| 536 | 607 | } |
| 537 | 608 | |
| 609 | +.date-filter-bar { | |
| 610 | + display: flex; | |
| 611 | + align-items: center; | |
| 612 | + gap: 20rpx; | |
| 613 | + padding: 20rpx 28rpx; | |
| 614 | + background: #fff; | |
| 615 | + border-bottom: 1rpx solid #e5e7eb; | |
| 616 | +} | |
| 617 | + | |
| 618 | +.date-field { | |
| 619 | + flex: 1; | |
| 620 | + min-width: 0; | |
| 621 | +} | |
| 622 | + | |
| 623 | +.today-btn { | |
| 624 | + flex-shrink: 0; | |
| 625 | + height: 72rpx; | |
| 626 | + padding: 0 28rpx; | |
| 627 | + border-radius: 12rpx; | |
| 628 | + border: 2rpx solid #e5e7eb; | |
| 629 | + background: #f9fafb; | |
| 630 | + display: flex; | |
| 631 | + align-items: center; | |
| 632 | + justify-content: center; | |
| 633 | +} | |
| 634 | + | |
| 635 | +.today-btn.active { | |
| 636 | + border-color: #1F3A8A; | |
| 637 | + background: #e8ecf5; | |
| 638 | +} | |
| 639 | + | |
| 640 | +.today-btn-text { | |
| 641 | + font-size: 26rpx; | |
| 642 | + font-weight: 600; | |
| 643 | + color: #374151; | |
| 644 | +} | |
| 645 | + | |
| 646 | +.today-btn.active .today-btn-text { | |
| 647 | + color: #1F3A8A; | |
| 648 | +} | |
| 649 | + | |
| 538 | 650 | .log-table-wrap { |
| 539 | 651 | background: #fff; |
| 540 | 652 | border-radius: 20rpx; |
| ... | ... | @@ -577,7 +689,7 @@ const goBack = () => { |
| 577 | 689 | padding: 24rpx 28rpx; |
| 578 | 690 | gap: 16rpx; |
| 579 | 691 | border-bottom: 1rpx solid #e5e7eb; |
| 580 | - align-items: center; | |
| 692 | + align-items: flex-start; | |
| 581 | 693 | font-size: 26rpx; |
| 582 | 694 | } |
| 583 | 695 | |
| ... | ... | @@ -586,15 +698,53 @@ const goBack = () => { |
| 586 | 698 | } |
| 587 | 699 | |
| 588 | 700 | .th, .td { |
| 701 | + box-sizing: border-box; | |
| 702 | +} | |
| 703 | + | |
| 704 | +.th-wrap, .td-wrap { | |
| 705 | + flex: 0 0 auto; | |
| 706 | + display: block; | |
| 707 | + white-space: normal; | |
| 708 | + word-break: break-word; | |
| 709 | + overflow-wrap: break-word; | |
| 710 | + line-height: 1.45; | |
| 711 | +} | |
| 712 | + | |
| 713 | +.th-fixed, .td-fixed { | |
| 589 | 714 | flex-shrink: 0; |
| 590 | 715 | white-space: nowrap; |
| 591 | 716 | } |
| 592 | 717 | |
| 593 | -.th-product, .td-product { flex: 0 0 auto; min-width: 140rpx; } | |
| 594 | -.th-id, .td-id { flex: 0 0 auto; min-width: 160rpx; } | |
| 595 | -.th-size, .td-size { flex: 0 0 auto; min-width: 220rpx; max-width: 360rpx; } | |
| 596 | -.th-type, .td-type { flex: 0 0 auto; min-width: 120rpx; } | |
| 597 | -.th-printed, .td-printed { flex: 0 0 auto; min-width: 200rpx; } | |
| 718 | +.th-product, .td-product { | |
| 719 | + width: 160rpx; | |
| 720 | + min-width: 160rpx; | |
| 721 | + max-width: 160rpx; | |
| 722 | +} | |
| 723 | + | |
| 724 | +.th-size, .td-size { | |
| 725 | + width: 220rpx; | |
| 726 | + min-width: 220rpx; | |
| 727 | + max-width: 220rpx; | |
| 728 | +} | |
| 729 | + | |
| 730 | +.th-label-id, .td-label-id { | |
| 731 | + width: 200rpx; | |
| 732 | + min-width: 200rpx; | |
| 733 | + max-width: 200rpx; | |
| 734 | +} | |
| 735 | + | |
| 736 | +.th-type, .td-type { | |
| 737 | + width: 200rpx; | |
| 738 | + min-width: 200rpx; | |
| 739 | + max-width: 200rpx; | |
| 740 | +} | |
| 741 | + | |
| 742 | +.th-printed, .td-printed { | |
| 743 | + width: 220rpx; | |
| 744 | + min-width: 220rpx; | |
| 745 | + max-width: 220rpx; | |
| 746 | +} | |
| 747 | + | |
| 598 | 748 | .th-action, .td-action { |
| 599 | 749 | flex: 0 0 80rpx; |
| 600 | 750 | width: 80rpx; |
| ... | ... | @@ -605,8 +755,8 @@ const goBack = () => { |
| 605 | 755 | } |
| 606 | 756 | |
| 607 | 757 | .td-product { color: #111827; font-weight: 500; } |
| 608 | -.td-id { color: #6b7280; font-size: 24rpx; } | |
| 609 | 758 | .td-size, |
| 759 | +.td-label-id, | |
| 610 | 760 | .td-type { color: #4b5563; font-size: 24rpx; } |
| 611 | 761 | .td-printed { color: #4b5563; } |
| 612 | 762 | ... | ... |
泰额版/Food Labeling Management App UniApp/src/pages/more/printers.vue
| ... | ... | @@ -449,6 +449,7 @@ const connectBt = async (device: any) => { |
| 449 | 449 | uni.hideLoading() |
| 450 | 450 | uni.showToast({ title: t('printers.connectSuccess') }) |
| 451 | 451 | } catch (e) { |
| 452 | + refreshStatus() | |
| 452 | 453 | uni.hideLoading() |
| 453 | 454 | uni.showToast({ title: t('printers.connectFail'), icon: 'none' }) |
| 454 | 455 | } finally { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/services/usAppLabeling.ts
| ... | ... | @@ -128,6 +128,7 @@ export async function fetchUsAppLabelingTree(input: { |
| 128 | 128 | }) |
| 129 | 129 | } |
| 130 | 130 | |
| 131 | +/** 离线缓存键:仅 location + labelCode + productId(不含 baseTime,避免同步预拉与预览页 key 不一致) */ | |
| 131 | 132 | export function buildLabelPreviewCacheKey(body: UsAppLabelPreviewInputVo): string { |
| 132 | 133 | const loc = String(body.locationId || '').trim() |
| 133 | 134 | const code = String(body.labelCode || '').trim() |
| ... | ... | @@ -470,19 +471,25 @@ export async function fetchUsAppLabelReport ( |
| 470 | 471 | return normalizeUsAppLabelReport(raw) |
| 471 | 472 | } |
| 472 | 473 | |
| 473 | -/** 接口 10:分页打印日志 */ | |
| 474 | +/** 接口 10:分页打印日志(6-18 支持 printDateDay / printDate 按自然日筛选) */ | |
| 474 | 475 | export async function fetchUsAppPrintLogList (input: PrintLogGetListInputVo) { |
| 475 | - const key = `print-log:${input.locationId}:${input.skipCount ?? 1}:${input.maxResultCount ?? 20}` | |
| 476 | + const printDateDay = (input.printDateDay || input.printDate || '').trim() | |
| 477 | + const dateKey = printDateDay || '__all_dates__' | |
| 478 | + const key = `print-log:${input.locationId}:${dateKey}:${input.skipCount ?? 1}:${input.maxResultCount ?? 20}` | |
| 476 | 479 | return fetchWithOfflineCache('labeling', key, async () => { |
| 480 | + const body: Record<string, unknown> = { | |
| 481 | + locationId: input.locationId, | |
| 482 | + skipCount: input.skipCount ?? 1, | |
| 483 | + maxResultCount: input.maxResultCount ?? 20, | |
| 484 | + } | |
| 485 | + if (printDateDay) { | |
| 486 | + body.printDateDay = printDateDay | |
| 487 | + } | |
| 477 | 488 | const raw = await usAppApiRequest<unknown>({ |
| 478 | 489 | path: '/api/app/us-app-labeling/get-print-log-list', |
| 479 | 490 | method: 'POST', |
| 480 | 491 | auth: true, |
| 481 | - data: { | |
| 482 | - locationId: input.locationId, | |
| 483 | - skipCount: input.skipCount ?? 1, | |
| 484 | - maxResultCount: input.maxResultCount ?? 20, | |
| 485 | - }, | |
| 492 | + data: body, | |
| 486 | 493 | }) |
| 487 | 494 | return extractPagedItems<PrintLogItemDto>(raw) |
| 488 | 495 | }) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/types/usAppLabeling.ts
| ... | ... | @@ -73,7 +73,10 @@ export interface UsAppLabelPreviewInputVo { |
| 73 | 73 | |
| 74 | 74 | /** 8.2 预览响应(解包后常见字段,供类型参考) */ |
| 75 | 75 | export interface UsAppLabelPreviewDto { |
| 76 | + /** 门店当日打印序号(yyyyMMdd-n),页脚 Label ID 与模板 Label ID 控件展示用 */ | |
| 76 | 77 | labelId?: string |
| 78 | + /** 与 labelId 同源(部分环境字段名) */ | |
| 79 | + printLabelDisplayId?: string | |
| 77 | 80 | labelLastEdited?: string |
| 78 | 81 | labelCode?: string |
| 79 | 82 | labelSizeText?: string | null |
| ... | ... | @@ -105,15 +108,24 @@ export interface UsAppLabelPrintOutputDto { |
| 105 | 108 | printQuantity: number |
| 106 | 109 | batchId?: string |
| 107 | 110 | taskIds?: string[] |
| 111 | + /** 模板含 Label ID 控件时:本批次各份 yyyyMMdd-n */ | |
| 112 | + printLabelDisplayIds?: string[] | |
| 108 | 113 | /** 接口 11:服务端返回的历史合并模板 JSON,供本地 BLE 打印 */ |
| 109 | 114 | mergedTemplateJson?: string | null |
| 110 | 115 | } |
| 111 | 116 | |
| 112 | -/** 接口 10 分页入参 */ | |
| 117 | +/** 接口 10 分页入参(6-18 get-print-log-list) */ | |
| 113 | 118 | export interface PrintLogGetListInputVo { |
| 114 | 119 | locationId: string |
| 115 | 120 | skipCount?: number |
| 116 | 121 | maxResultCount?: number |
| 122 | + /** | |
| 123 | + * 打印日期(自然日),优先使用 `printDateDay`(`yyyy-MM-dd`),避免 JSON DateTime UTC 歧义。 | |
| 124 | + * 与 `printDate` 等价;二者均未传时不按日过滤(返回该门店全部记录)。 | |
| 125 | + */ | |
| 126 | + printDateDay?: string | |
| 127 | + /** @deprecated 请用 printDateDay;仍可与 printDateDay 二选一传参 */ | |
| 128 | + printDate?: string | |
| 117 | 129 | } |
| 118 | 130 | |
| 119 | 131 | /** 接口 10 打印明细项(元素快照) */ | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/apiBase.ts
| 1 | 1 | /** |
| 2 | - * 与 vite `server.proxy` 默认 target 一致;H5 开发无 VITE_US_API_BASE 时用于拼图片等静态资源全路径。 | |
| 3 | - * 修改后端环境时请同步改 vite.config.ts 的 proxy.target。 | |
| 2 | + * 本地/打包未配置 VITE_US_API_BASE 时的兜底后端根地址(不含末尾 /)。 | |
| 3 | + * 调试本地时请与 .env.development 保持一致;仅作 fallback,优先使用环境变量。 | |
| 4 | 4 | */ |
| 5 | -export const US_BACKEND_ORIGIN_FALLBACK = 'http://flus-test.3ffoodsafety.com' | |
| 5 | +// export const US_BACKEND_ORIGIN_FALLBACK = 'http://flus-test.3ffoodsafety.com' | |
| 6 | +export const US_BACKEND_ORIGIN_FALLBACK = 'http://192.168.31.89:19001' | |
| 6 | 7 | |
| 7 | 8 | /** |
| 8 | - * 美国版后端 API 根地址(不含末尾 /)。 | |
| 9 | - * - H5 开发:可在 .env.development 留空,配合 vite proxy 走同源 /api | |
| 10 | - * - App / 生产:在 .env 中设置 VITE_US_API_BASE,例如 http://192.168.1.10:19001 | |
| 9 | + * 解析美国版后端根地址(不含末尾 /)。 | |
| 10 | + * 优先级:VITE_US_API_BASE(.env.development / .env.production)> US_BACKEND_ORIGIN_FALLBACK | |
| 11 | + * H5 开发与 App 打包均直连后端,不再经 Vite proxy。 | |
| 11 | 12 | */ |
| 12 | -export function getApiBaseUrl(): string { | |
| 13 | +export function resolveUsBackendOrigin(): string { | |
| 13 | 14 | const fromEnv = (import.meta.env.VITE_US_API_BASE as string | undefined)?.trim() |
| 14 | 15 | if (fromEnv) return fromEnv.replace(/\/$/, '') |
| 15 | - if (import.meta.env.DEV && typeof window !== 'undefined') return '' | |
| 16 | 16 | return US_BACKEND_ORIGIN_FALLBACK |
| 17 | 17 | } |
| 18 | 18 | |
| 19 | -/** | |
| 20 | - * 图片等静态资源根(与 API 同域)。H5 开发时 API 仍用相对路径走 Vite /api 代理, | |
| 21 | - * 但 /picture/... 若不加域名会请求到 dev server 导致 404,故在此返回后端根域。 | |
| 22 | - */ | |
| 19 | +/** 美国版后端 API 根地址(不含末尾 /) */ | |
| 20 | +export function getApiBaseUrl(): string { | |
| 21 | + return resolveUsBackendOrigin() | |
| 22 | +} | |
| 23 | + | |
| 24 | +/** 图片等静态资源根(与 API 同域) */ | |
| 23 | 25 | export function getStaticMediaOrigin(): string { |
| 24 | - const apiBase = getApiBaseUrl() | |
| 25 | - if (apiBase) return apiBase | |
| 26 | - if (import.meta.env.DEV && typeof window !== 'undefined') { | |
| 27 | - return US_BACKEND_ORIGIN_FALLBACK | |
| 28 | - } | |
| 29 | - return '' | |
| 26 | + return resolveUsBackendOrigin() | |
| 30 | 27 | } |
| 31 | 28 | |
| 32 | 29 | export function buildApiUrl(path: string): string { |
| 33 | 30 | const base = getApiBaseUrl() |
| 34 | 31 | const p = path.startsWith('/') ? path : `/${path}` |
| 35 | - return base ? `${base}${p}` : p | |
| 32 | + return `${base}${p}` | |
| 36 | 33 | } | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/deviceInfo.ts
| ... | ... | @@ -61,3 +61,24 @@ export function getDeviceFingerprint (): string { |
| 61 | 61 | .join(' | ') |
| 62 | 62 | .toLowerCase() |
| 63 | 63 | } |
| 64 | + | |
| 65 | +/** Gprinter AOA 一体机(rk3568 / 型号含 AOA):走内置 UPOS/TCP,勿依赖外接蓝牙扫描 */ | |
| 66 | +export function isAoaAioDevice (): boolean { | |
| 67 | + const identity = getDeviceIdentity() | |
| 68 | + const fingerprint = getDeviceFingerprint() | |
| 69 | + const blob = [ | |
| 70 | + identity.model, | |
| 71 | + identity.product, | |
| 72 | + identity.device, | |
| 73 | + identity.brand, | |
| 74 | + fingerprint, | |
| 75 | + ] | |
| 76 | + .map((s) => String(s || '').toLowerCase()) | |
| 77 | + .join(' ') | |
| 78 | + if (!blob.trim()) return false | |
| 79 | + return ( | |
| 80 | + blob.includes('aoa_rk3568') | |
| 81 | + || blob.includes('rk3568') | |
| 82 | + || /\baoa\b/.test(blob) | |
| 83 | + ) | |
| 84 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/imageScaleMode.ts
0 → 100644
| 1 | +export type ImageScaleMode = 'contain' | 'cover' | 'fill' | |
| 2 | + | |
| 3 | +export function readImageScaleMode(cfg: Record<string, unknown> | undefined | null): ImageScaleMode { | |
| 4 | + const v = String(cfg?.scaleMode ?? cfg?.ScaleMode ?? 'contain') | |
| 5 | + .trim() | |
| 6 | + .toLowerCase() | |
| 7 | + if (v === 'cover' || v === 'fill') return v | |
| 8 | + return 'contain' | |
| 9 | +} | |
| 10 | + | |
| 11 | +export function computeImageDrawRect( | |
| 12 | + boxW: number, | |
| 13 | + boxH: number, | |
| 14 | + sourceW: number, | |
| 15 | + sourceH: number, | |
| 16 | + scaleMode: string, | |
| 17 | +): { dx: number; dy: number; dw: number; dh: number } { | |
| 18 | + const mode = String(scaleMode ?? 'contain').trim().toLowerCase() | |
| 19 | + if (sourceW <= 0 || sourceH <= 0 || mode === 'fill') { | |
| 20 | + return { dx: 0, dy: 0, dw: boxW, dh: boxH } | |
| 21 | + } | |
| 22 | + const ratio = | |
| 23 | + mode === 'cover' | |
| 24 | + ? Math.max(boxW / sourceW, boxH / sourceH) | |
| 25 | + : Math.min(boxW / sourceW, boxH / sourceH) | |
| 26 | + const dw = Math.max(1, Math.round(sourceW * ratio)) | |
| 27 | + const dh = Math.max(1, Math.round(sourceH * ratio)) | |
| 28 | + return { | |
| 29 | + dx: Math.round((boxW - dw) / 2), | |
| 30 | + dy: Math.round((boxH - dh) / 2), | |
| 31 | + dw, | |
| 32 | + dh, | |
| 33 | + } | |
| 34 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/invertColorsConfig.ts
0 → 100644
| 1 | +/** 元素 config.invertColors:黑底白字(预览与打印一致) */ | |
| 2 | +export function readInvertColors( | |
| 3 | + config: Record<string, unknown> | undefined | null, | |
| 4 | +): boolean { | |
| 5 | + if (!config) return false | |
| 6 | + const v = config.invertColors ?? config.InvertColors | |
| 7 | + if (v === true || v === 1) return true | |
| 8 | + if (typeof v === 'string') { | |
| 9 | + const s = v.trim().toLowerCase() | |
| 10 | + return s === 'true' || s === '1' || s === 'yes' | |
| 11 | + } | |
| 12 | + return false | |
| 13 | +} | |
| 14 | + | |
| 15 | +export const INVERT_COLORS_BG = '#000000' | |
| 16 | +export const INVERT_COLORS_FG = '#ffffff' | |
| 17 | + | |
| 18 | +/** 模板是否含黑底白字元素(打印光栅需额外锐化) */ | |
| 19 | +export function templateHasInvertedTextElements( | |
| 20 | + template: { elements?: Array<{ config?: Record<string, unknown> | null }> | null }, | |
| 21 | +): boolean { | |
| 22 | + for (const el of template.elements || []) { | |
| 23 | + if (readInvertColors(el.config)) return true | |
| 24 | + } | |
| 25 | + return false | |
| 26 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelEditorFonts.ts
0 → 100644
| 1 | +import type { SystemLabelTemplate } from './print/types/printer' | |
| 2 | + | |
| 3 | +/** 与 Web 管理端 labelTemplate.ts 保持一致 */ | |
| 4 | +export const LABEL_EDITOR_FONT_FAMILY = 'FreightSans Bold' | |
| 5 | + | |
| 6 | +export const LABEL_EDITOR_FONT_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [ | |
| 7 | + { value: 'FreightSans Bold', label: 'FreightSans Bold' }, | |
| 8 | + { value: 'Roboto', label: 'Roboto' }, | |
| 9 | + { value: 'Open Sans', label: 'Open Sans' }, | |
| 10 | + { value: 'Lato', label: 'Lato' }, | |
| 11 | + { value: 'Tinos', label: 'Tinos' }, | |
| 12 | + { value: 'Roboto Mono', label: 'Roboto Mono' }, | |
| 13 | +] | |
| 14 | + | |
| 15 | +export const LABEL_EDITOR_BUNDLED_FONT_FAMILIES = new Set( | |
| 16 | + LABEL_EDITOR_FONT_OPTIONS.map((item) => item.value), | |
| 17 | +) | |
| 18 | + | |
| 19 | +const LEGACY_FONT_ALIASES: Record<string, string> = { | |
| 20 | + arial: 'Roboto', | |
| 21 | + 'arial, sans-serif': 'Roboto', | |
| 22 | + helvetica: 'Roboto', | |
| 23 | + 'helvetica, sans-serif': 'Roboto', | |
| 24 | + 'times new roman': 'Tinos', | |
| 25 | + 'courier new': 'Roboto Mono', | |
| 26 | + verdana: 'Open Sans', | |
| 27 | + georgia: 'Tinos', | |
| 28 | +} | |
| 29 | + | |
| 30 | +type FontVariantSpec = { | |
| 31 | + file: string | |
| 32 | + weight: 'normal' | 'bold' | |
| 33 | + style: 'normal' | 'italic' | |
| 34 | +} | |
| 35 | + | |
| 36 | +type FontPackageSpec = { | |
| 37 | + family: string | |
| 38 | + dir: string | |
| 39 | + variants: FontVariantSpec[] | |
| 40 | +} | |
| 41 | + | |
| 42 | +/** static/fonts 下已打包字体(与 Web src/assets/fonts 对齐) */ | |
| 43 | +export const LABEL_EDITOR_FONT_PACKAGES: FontPackageSpec[] = [ | |
| 44 | + { | |
| 45 | + family: 'FreightSans Bold', | |
| 46 | + dir: 'freight-sans-bold', | |
| 47 | + variants: [{ file: 'FreightSans-Bold.ttf', weight: 'bold', style: 'normal' }], | |
| 48 | + }, | |
| 49 | + { | |
| 50 | + family: 'Roboto', | |
| 51 | + dir: 'roboto', | |
| 52 | + variants: [ | |
| 53 | + { file: 'roboto-latin-400-normal.woff2', weight: 'normal', style: 'normal' }, | |
| 54 | + { file: 'roboto-latin-700-normal.woff2', weight: 'bold', style: 'normal' }, | |
| 55 | + { file: 'roboto-latin-400-italic.woff2', weight: 'normal', style: 'italic' }, | |
| 56 | + ], | |
| 57 | + }, | |
| 58 | + { | |
| 59 | + family: 'Open Sans', | |
| 60 | + dir: 'open-sans', | |
| 61 | + variants: [ | |
| 62 | + { file: 'open-sans-latin-400-normal.woff2', weight: 'normal', style: 'normal' }, | |
| 63 | + { file: 'open-sans-latin-700-normal.woff2', weight: 'bold', style: 'normal' }, | |
| 64 | + { file: 'open-sans-latin-400-italic.woff2', weight: 'normal', style: 'italic' }, | |
| 65 | + ], | |
| 66 | + }, | |
| 67 | + { | |
| 68 | + family: 'Lato', | |
| 69 | + dir: 'lato', | |
| 70 | + variants: [ | |
| 71 | + { file: 'lato-latin-400-normal.woff2', weight: 'normal', style: 'normal' }, | |
| 72 | + { file: 'lato-latin-700-normal.woff2', weight: 'bold', style: 'normal' }, | |
| 73 | + { file: 'lato-latin-400-italic.woff2', weight: 'normal', style: 'italic' }, | |
| 74 | + ], | |
| 75 | + }, | |
| 76 | + { | |
| 77 | + family: 'Tinos', | |
| 78 | + dir: 'tinos', | |
| 79 | + variants: [ | |
| 80 | + { file: 'tinos-latin-400-normal.woff2', weight: 'normal', style: 'normal' }, | |
| 81 | + { file: 'tinos-latin-700-normal.woff2', weight: 'bold', style: 'normal' }, | |
| 82 | + { file: 'tinos-latin-400-italic.woff2', weight: 'normal', style: 'italic' }, | |
| 83 | + ], | |
| 84 | + }, | |
| 85 | + { | |
| 86 | + family: 'Roboto Mono', | |
| 87 | + dir: 'roboto-mono', | |
| 88 | + variants: [ | |
| 89 | + { file: 'roboto-mono-latin-400-normal.woff2', weight: 'normal', style: 'normal' }, | |
| 90 | + { file: 'roboto-mono-latin-700-normal.woff2', weight: 'bold', style: 'normal' }, | |
| 91 | + { file: 'roboto-mono-latin-400-italic.woff2', weight: 'normal', style: 'italic' }, | |
| 92 | + ], | |
| 93 | + }, | |
| 94 | +] | |
| 95 | + | |
| 96 | +const loadedFaceKeys = new Set<string>() | |
| 97 | +const loadedFamilies = new Set<string>() | |
| 98 | +let preloadAllPromise: Promise<void> | null = null | |
| 99 | + | |
| 100 | +export function normalizeLabelEditorFontFamily(raw: unknown): string | null { | |
| 101 | + const text = String(raw ?? '').trim() | |
| 102 | + if (!text) return null | |
| 103 | + const alias = LEGACY_FONT_ALIASES[text.toLowerCase()] | |
| 104 | + if (alias) return alias | |
| 105 | + if (LABEL_EDITOR_BUNDLED_FONT_FAMILIES.has(text)) return text | |
| 106 | + return null | |
| 107 | +} | |
| 108 | + | |
| 109 | +export function resolveLabelEditorFontFamily( | |
| 110 | + config: Record<string, unknown> | null | undefined, | |
| 111 | +): string { | |
| 112 | + return ( | |
| 113 | + normalizeLabelEditorFontFamily(config?.fontFamily ?? config?.FontFamily) | |
| 114 | + ?? LABEL_EDITOR_FONT_FAMILY | |
| 115 | + ) | |
| 116 | +} | |
| 117 | + | |
| 118 | +function fontPackageByFamily(family: string): FontPackageSpec | undefined { | |
| 119 | + return LABEL_EDITOR_FONT_PACKAGES.find((item) => item.family === family) | |
| 120 | +} | |
| 121 | + | |
| 122 | +/** static/fonts/{dir}/{file} → 各端可访问的本地 URL */ | |
| 123 | +export function resolveStaticFontFileUrl(dir: string, file: string): string { | |
| 124 | + const rel = `static/fonts/${dir}/${file}`.replace(/\\/g, '/') | |
| 125 | + // #ifdef APP-PLUS | |
| 126 | + try { | |
| 127 | + if (typeof plus !== 'undefined' && plus.io?.convertLocalFileSystemURL) { | |
| 128 | + return plus.io.convertLocalFileSystemURL(`_www/${rel}`) | |
| 129 | + } | |
| 130 | + } catch (_) { | |
| 131 | + /* fall through */ | |
| 132 | + } | |
| 133 | + // #endif | |
| 134 | + return `/${rel}` | |
| 135 | +} | |
| 136 | + | |
| 137 | +/** Android 原生位图文字:Typeface.createFromFile 用的绝对路径 */ | |
| 138 | +export function resolveAndroidFontFilePath( | |
| 139 | + family: string, | |
| 140 | + bold: boolean, | |
| 141 | + italic: boolean, | |
| 142 | +): string | null { | |
| 143 | + const pkg = fontPackageByFamily(family) | |
| 144 | + if (!pkg) return null | |
| 145 | + const weight = bold ? 'bold' : 'normal' | |
| 146 | + const style = italic ? 'italic' : 'normal' | |
| 147 | + let variant = pkg.variants.find((v) => v.weight === weight && v.style === style) | |
| 148 | + if (!variant && bold) { | |
| 149 | + variant = pkg.variants.find((v) => v.weight === 'bold' && v.style === 'normal') | |
| 150 | + } | |
| 151 | + if (!variant) { | |
| 152 | + variant = pkg.variants.find((v) => v.weight === 'normal' && v.style === 'normal') | |
| 153 | + } | |
| 154 | + if (!variant) return null | |
| 155 | + return resolveStaticFontFileUrl(pkg.dir, variant.file) | |
| 156 | +} | |
| 157 | + | |
| 158 | +function loadFontFaceOnce( | |
| 159 | + family: string, | |
| 160 | + dir: string, | |
| 161 | + variant: FontVariantSpec, | |
| 162 | +): Promise<void> { | |
| 163 | + const key = `${family}|${variant.weight}|${variant.style}|${variant.file}` | |
| 164 | + if (loadedFaceKeys.has(key)) return Promise.resolve() | |
| 165 | + const url = resolveStaticFontFileUrl(dir, variant.file) | |
| 166 | + return new Promise((resolve) => { | |
| 167 | + uni.loadFontFace({ | |
| 168 | + global: true, | |
| 169 | + family, | |
| 170 | + source: `url("${url}")`, | |
| 171 | + desc: { | |
| 172 | + style: variant.style, | |
| 173 | + weight: variant.weight, | |
| 174 | + }, | |
| 175 | + success: () => { | |
| 176 | + loadedFaceKeys.add(key) | |
| 177 | + loadedFamilies.add(family) | |
| 178 | + if (variant.style === 'italic') loadedItalicFamilies.add(family) | |
| 179 | + resolve() | |
| 180 | + }, | |
| 181 | + fail: (err) => { | |
| 182 | + console.warn('[labelEditorFonts] loadFontFace failed', family, variant, err) | |
| 183 | + resolve() | |
| 184 | + }, | |
| 185 | + }) | |
| 186 | + }) | |
| 187 | +} | |
| 188 | + | |
| 189 | +export function isLabelEditorFontFamilyLoaded(family: string): boolean { | |
| 190 | + return loadedFamilies.has(family) | |
| 191 | +} | |
| 192 | + | |
| 193 | +const loadedItalicFamilies = new Set<string>() | |
| 194 | + | |
| 195 | +export function isLabelEditorFontItalicLoaded(family: string): boolean { | |
| 196 | + return loadedItalicFamilies.has(family) | |
| 197 | +} | |
| 198 | + | |
| 199 | +export async function ensureLabelEditorFontFamilyLoaded(family: string): Promise<void> { | |
| 200 | + const normalized = normalizeLabelEditorFontFamily(family) ?? LABEL_EDITOR_FONT_FAMILY | |
| 201 | + const pkg = fontPackageByFamily(normalized) | |
| 202 | + if (!pkg) return | |
| 203 | + await Promise.all( | |
| 204 | + pkg.variants.map((variant) => loadFontFaceOnce(pkg.family, pkg.dir, variant)), | |
| 205 | + ) | |
| 206 | +} | |
| 207 | + | |
| 208 | +export function collectFontFamiliesFromTemplate(template: SystemLabelTemplate): string[] { | |
| 209 | + const set = new Set<string>([LABEL_EDITOR_FONT_FAMILY]) | |
| 210 | + for (const el of template.elements || []) { | |
| 211 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 212 | + const family = normalizeLabelEditorFontFamily(cfg.fontFamily ?? cfg.FontFamily) | |
| 213 | + if (family) set.add(family) | |
| 214 | + } | |
| 215 | + return [...set] | |
| 216 | +} | |
| 217 | + | |
| 218 | +/** 预览 / 打印 canvas 绘制前调用,加载模板用到的字体 */ | |
| 219 | +export async function ensureLabelEditorFontsForTemplate( | |
| 220 | + template: SystemLabelTemplate, | |
| 221 | +): Promise<void> { | |
| 222 | + const families = collectFontFamiliesFromTemplate(template) | |
| 223 | + await Promise.all(families.map((family) => ensureLabelEditorFontFamilyLoaded(family))) | |
| 224 | +} | |
| 225 | + | |
| 226 | +/** App 启动后后台预加载全部打包字体,减少首次预览等待 */ | |
| 227 | +export function preloadAllLabelEditorFonts(): Promise<void> { | |
| 228 | + if (!preloadAllPromise) { | |
| 229 | + preloadAllPromise = Promise.all( | |
| 230 | + LABEL_EDITOR_FONT_PACKAGES.map((pkg) => ensureLabelEditorFontFamilyLoaded(pkg.family)), | |
| 231 | + ).then(() => undefined) | |
| 232 | + } | |
| 233 | + return preloadAllPromise | |
| 234 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/employeeElement.ts
0 → 100644
| 1 | +import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' | |
| 2 | + | |
| 3 | +function normalizePaletteSlug (raw: string): string { | |
| 4 | + return String(raw ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '') | |
| 5 | +} | |
| 6 | + | |
| 7 | +function readTypeAdd (el: SystemTemplateElementBase): string { | |
| 8 | + return String((el as SystemTemplateElementBase & { typeAdd?: string }).typeAdd ?? '').trim().toLowerCase() | |
| 9 | +} | |
| 10 | + | |
| 11 | +/** 当前登录用户展示名(与侧栏 / Profile 一致) */ | |
| 12 | +export function getLoggedInEmployeeDisplayName (): string { | |
| 13 | + const name = String(uni.getStorageSync('userName') || '').trim() | |
| 14 | + if (name) return name | |
| 15 | + const email = String(uni.getStorageSync('user_email') || '').trim() | |
| 16 | + if (email) { | |
| 17 | + const at = email.indexOf('@') | |
| 18 | + return at > 0 ? email.slice(0, at) : email | |
| 19 | + } | |
| 20 | + return 'Employee' | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** 是否为模板「Entered Automatically → Employee」控件 */ | |
| 24 | +export function isEmployeeTemplateElement (el: SystemTemplateElementBase): boolean { | |
| 25 | + const typeAdd = readTypeAdd(el) | |
| 26 | + if (typeAdd.startsWith('auto_') && typeAdd.includes('employee')) return true | |
| 27 | + | |
| 28 | + const en = normalizePaletteSlug(String(el.elementName ?? '')) | |
| 29 | + if (/^employee\d*$/.test(en)) return true | |
| 30 | + | |
| 31 | + const vst = String(el.valueSourceType ?? '').toUpperCase() | |
| 32 | + const type = String(el.type ?? '').toUpperCase() | |
| 33 | + if (vst === 'AUTO_DB' && type === 'TEXT_STATIC' && /^employee/.test(en)) return true | |
| 34 | + | |
| 35 | + return false | |
| 36 | +} | |
| 37 | + | |
| 38 | +export function templateIncludesEmployeeElement ( | |
| 39 | + template: SystemLabelTemplate | null | undefined, | |
| 40 | +): boolean { | |
| 41 | + return !!(template?.elements?.some(isEmployeeTemplateElement)) | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** 将当前登录人姓名写入 Employee AUTO 控件(预览/出纸同源) */ | |
| 45 | +export function applyEmployeeDisplayToTemplate ( | |
| 46 | + template: SystemLabelTemplate, | |
| 47 | + employeeName?: string | null, | |
| 48 | +): SystemLabelTemplate { | |
| 49 | + const name = String(employeeName ?? '').trim() || getLoggedInEmployeeDisplayName() | |
| 50 | + const elements = (template.elements || []).map((el) => { | |
| 51 | + if (!isEmployeeTemplateElement(el)) return el | |
| 52 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 53 | + return { | |
| 54 | + ...el, | |
| 55 | + config: { ...cfg, text: name, Text: name }, | |
| 56 | + } | |
| 57 | + }) | |
| 58 | + return { ...template, elements } | |
| 59 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/labelIdElement.ts
0 → 100644
| 1 | +import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' | |
| 2 | + | |
| 3 | +function normalizePaletteSlug (raw: string): string { | |
| 4 | + return String(raw ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '') | |
| 5 | +} | |
| 6 | + | |
| 7 | +function readTypeAdd (el: SystemTemplateElementBase): string { | |
| 8 | + return String((el as SystemTemplateElementBase & { typeAdd?: string }).typeAdd ?? '').trim().toLowerCase() | |
| 9 | +} | |
| 10 | + | |
| 11 | +/** 是否为模板「Entered Automatically → Label ID」控件 */ | |
| 12 | +export function isLabelIdTemplateElement (el: SystemTemplateElementBase): boolean { | |
| 13 | + const typeAdd = readTypeAdd(el) | |
| 14 | + if (typeAdd.startsWith('auto_') && typeAdd.includes('label id')) return true | |
| 15 | + | |
| 16 | + const en = normalizePaletteSlug(String(el.elementName ?? '')) | |
| 17 | + if (/^labelid\d*$/.test(en)) return true | |
| 18 | + | |
| 19 | + const vst = String(el.valueSourceType ?? '').toUpperCase() | |
| 20 | + const type = String(el.type ?? '').toUpperCase() | |
| 21 | + if (vst === 'AUTO_DB' && type === 'TEXT_STATIC' && /^labelid/.test(en)) return true | |
| 22 | + | |
| 23 | + return false | |
| 24 | +} | |
| 25 | + | |
| 26 | +export function templateIncludesLabelIdElement ( | |
| 27 | + template: SystemLabelTemplate | null | undefined, | |
| 28 | +): boolean { | |
| 29 | + return !!(template?.elements?.some(isLabelIdTemplateElement)) | |
| 30 | +} | |
| 31 | + | |
| 32 | +/** 按元素 config.prefix 拼展示行(避免重复前缀) */ | |
| 33 | +export function formatLabelIdDisplayLine ( | |
| 34 | + rawId: string, | |
| 35 | + config?: Record<string, unknown>, | |
| 36 | +): string { | |
| 37 | + const id = String(rawId ?? '').trim() | |
| 38 | + const prefix = String(config?.prefix ?? config?.Prefix ?? 'LABEL ID: ').trim() | |
| 39 | + if (!id) return prefix ? `${prefix}—` : '—' | |
| 40 | + const lowerPrefix = prefix.toLowerCase() | |
| 41 | + if (prefix && id.toLowerCase().startsWith(lowerPrefix)) return id | |
| 42 | + if (!prefix) return id | |
| 43 | + const needsSpace = !prefix.endsWith(':') && !prefix.endsWith(' ') | |
| 44 | + return needsSpace ? `${prefix} ${id}` : `${prefix}${id}` | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** 将 Label ID 写入模板内对应 AUTO 控件(预览/出纸同源) */ | |
| 48 | +export function applyLabelIdDisplayToTemplate ( | |
| 49 | + template: SystemLabelTemplate, | |
| 50 | + labelIdText: string, | |
| 51 | +): SystemLabelTemplate { | |
| 52 | + const elements = (template.elements || []).map((el) => { | |
| 53 | + if (!isLabelIdTemplateElement(el)) return el | |
| 54 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 55 | + const line = formatLabelIdDisplayLine(labelIdText, cfg) | |
| 56 | + return { | |
| 57 | + ...el, | |
| 58 | + config: { ...cfg, text: line, Text: line }, | |
| 59 | + } | |
| 60 | + }) | |
| 61 | + return { ...template, elements } | |
| 62 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/normalizePreviewTemplate.ts
| 1 | 1 | import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' |
| 2 | -import { resolveTemplateDefaultValueForElement } from './printInputOffset' | |
| 3 | -import { applyNutritionDefaultJsonToConfig } from './nutritionDefaultsMerge' | |
| 2 | +import { | |
| 3 | + applyLiveDateTimeFieldsToTemplate, | |
| 4 | + captureDateDisplayFormatInConfig, | |
| 5 | + isUniAppDateTimeOffsetField, | |
| 6 | + parseStoredPrintInputOffset, | |
| 7 | + resolveTemplateDefaultValueForElement, | |
| 8 | + sanitizeDateElementConfig, | |
| 9 | + tryParsePrintInputOffsetStored, | |
| 10 | +} from './printInputOffset' | |
| 11 | +import { | |
| 12 | + applyNutritionDefaultJsonToConfig, | |
| 13 | + collectNutritionManualFromDefaults, | |
| 14 | + hydrateNutritionElementsInTemplate, | |
| 15 | + expandNutritionElementsToFitContent, | |
| 16 | +} from './nutritionDefaultsMerge' | |
| 17 | + | |
| 18 | +export { expandNutritionElementsToFitContent, reflowElementsBelowNutritionPanel } | |
| 4 | 19 | |
| 5 | 20 | function asRecord(v: unknown): Record<string, unknown> { |
| 6 | 21 | if (v != null && typeof v === 'object' && !Array.isArray(v)) return v as Record<string, unknown> |
| 7 | 22 | return {} |
| 8 | 23 | } |
| 9 | 24 | |
| 25 | +const KNOWN_ELEMENT_TYPES = new Set([ | |
| 26 | + 'TEXT_STATIC', | |
| 27 | + 'TEXT', | |
| 28 | + 'TEXT_PRODUCT', | |
| 29 | + 'TEXT_PRICE', | |
| 30 | + 'TEXT_CATEGORY', | |
| 31 | + 'TEXT_LABEL_ID', | |
| 32 | + 'BARCODE', | |
| 33 | + 'QRCODE', | |
| 34 | + 'IMAGE', | |
| 35 | + 'LOGO', | |
| 36 | + 'DATE', | |
| 37 | + 'TIME', | |
| 38 | + 'DURATION', | |
| 39 | + 'WEIGHT', | |
| 40 | + 'NUTRITION', | |
| 41 | +]) | |
| 42 | + | |
| 43 | +/** 与 Web canonicalElementType 对齐:print|Number → TEXT_STATIC 等 */ | |
| 44 | +function canonicalElementType (raw: string): string { | |
| 45 | + const type = String(raw ?? '').trim() | |
| 46 | + if (!type) return 'TEXT_STATIC' | |
| 47 | + const upper = type.toUpperCase() | |
| 48 | + if (KNOWN_ELEMENT_TYPES.has(upper)) return upper | |
| 49 | + const m = type.match(/^([^|]+)\|(.+)$/i) | |
| 50 | + if (!m) return 'TEXT_STATIC' | |
| 51 | + const group = m[1].trim().toLowerCase() | |
| 52 | + const label = m[2].trim().toLowerCase() | |
| 53 | + if (group === 'print') { | |
| 54 | + if (label === 'number' || label === 'text' || label === 'multiple options') return 'TEXT_STATIC' | |
| 55 | + if (label === 'weight') return 'WEIGHT' | |
| 56 | + if (label === 'date & time' || label === 'date and time') return 'DATE' | |
| 57 | + } | |
| 58 | + if (group === 'auto') { | |
| 59 | + if (label.includes('current date')) return 'DATE' | |
| 60 | + if (label.includes('current time')) return 'TIME' | |
| 61 | + } | |
| 62 | + if (group === 'label' && label.includes('duration') && !label.includes('date')) return 'DURATION' | |
| 63 | + return 'TEXT_STATIC' | |
| 64 | +} | |
| 65 | + | |
| 10 | 66 | function normalizeConfig(raw: unknown): Record<string, unknown> { |
| 11 | 67 | if (raw == null) return {} |
| 12 | 68 | if (typeof raw === 'string') { |
| ... | ... | @@ -46,8 +102,16 @@ function mergeFlatElementFieldsIntoConfig( |
| 46 | 102 | 'FontSize', |
| 47 | 103 | 'textAlign', |
| 48 | 104 | 'TextAlign', |
| 105 | + 'verticalAlign', | |
| 106 | + 'VerticalAlign', | |
| 49 | 107 | 'fontFamily', |
| 108 | + 'FontFamily', | |
| 50 | 109 | 'fontWeight', |
| 110 | + 'FontWeight', | |
| 111 | + 'fontStyle', | |
| 112 | + 'FontStyle', | |
| 113 | + 'textDecoration', | |
| 114 | + 'TextDecoration', | |
| 51 | 115 | 'color', |
| 52 | 116 | 'Color', |
| 53 | 117 | 'src', |
| ... | ... | @@ -76,9 +140,13 @@ function mergeFlatElementFieldsIntoConfig( |
| 76 | 140 | 'SelectedOptionValues', |
| 77 | 141 | 'errorLevel', |
| 78 | 142 | 'scaleMode', |
| 143 | + 'weightInputMode', | |
| 144 | + 'WeightInputMode', | |
| 79 | 145 | 'showText', |
| 80 | 146 | 'placeholder', |
| 81 | 147 | 'Placeholder', |
| 148 | + 'invertColors', | |
| 149 | + 'InvertColors', | |
| 82 | 150 | ] as const |
| 83 | 151 | for (const k of keys) { |
| 84 | 152 | const existing = out[k] |
| ... | ... | @@ -232,6 +300,41 @@ export function applyLabelSizeTextToTemplate( |
| 232 | 300 | } |
| 233 | 301 | |
| 234 | 302 | /** |
| 303 | + * 将接口/缓存中的默认值统一为字符串(兼容 object、双重 JSON 编码、PascalCase unit/value)。 | |
| 304 | + */ | |
| 305 | +export function coerceTemplateProductDefaultValueToString(v: unknown): string { | |
| 306 | + if (v == null) return '' | |
| 307 | + if (typeof v === 'string') { | |
| 308 | + const t = v.trim() | |
| 309 | + if (!t) return '' | |
| 310 | + if (t.startsWith('{') || t.startsWith('[')) return t | |
| 311 | + if (t.startsWith('"') && t.endsWith('"')) { | |
| 312 | + try { | |
| 313 | + const inner = JSON.parse(t) as unknown | |
| 314 | + if (typeof inner === 'string') return inner.trim() | |
| 315 | + } catch { | |
| 316 | + /* ignore */ | |
| 317 | + } | |
| 318 | + } | |
| 319 | + return t | |
| 320 | + } | |
| 321 | + if (typeof v === 'number' || typeof v === 'boolean') return String(v) | |
| 322 | + if (typeof v === 'object' && !Array.isArray(v)) { | |
| 323 | + const rec = v as Record<string, unknown> | |
| 324 | + const unit = rec.unit ?? rec.Unit | |
| 325 | + const value = rec.value ?? rec.Value | |
| 326 | + if (unit != null && value != null) { | |
| 327 | + return JSON.stringify({ unit: String(unit), value: String(value) }) | |
| 328 | + } | |
| 329 | + } | |
| 330 | + try { | |
| 331 | + return JSON.stringify(v) | |
| 332 | + } catch { | |
| 333 | + return String(v) | |
| 334 | + } | |
| 335 | +} | |
| 336 | + | |
| 337 | +/** | |
| 235 | 338 | * 从预览接口响应中取出 `templateProductDefaultValues`(elementId → 字符串,兼容 PascalCase / 嵌套 data)。 |
| 236 | 339 | */ |
| 237 | 340 | export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: unknown): Record<string, string> { |
| ... | ... | @@ -244,10 +347,7 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u |
| 244 | 347 | for (const [k, v] of Object.entries(raw as Record<string, unknown>)) { |
| 245 | 348 | const key = String(k).trim() |
| 246 | 349 | if (!key) continue |
| 247 | - if (v == null) out[key] = '' | |
| 248 | - else if (typeof v === 'string') out[key] = v | |
| 249 | - else if (typeof v === 'number' || typeof v === 'boolean') out[key] = String(v) | |
| 250 | - else out[key] = JSON.stringify(v) | |
| 350 | + out[key] = coerceTemplateProductDefaultValueToString(v) | |
| 251 | 351 | } |
| 252 | 352 | return Object.keys(out).length ? out : null |
| 253 | 353 | } |
| ... | ... | @@ -300,9 +400,32 @@ export function extractTemplateProductDefaultValuesFromPreviewPayload(payload: u |
| 300 | 400 | inner ? tryLayer(inner) : null, |
| 301 | 401 | tryLayer(r), |
| 302 | 402 | tryLayer(payload), |
| 403 | + tryExtractFromTemplateProductDefaultsArray(r), | |
| 404 | + inner ? tryExtractFromTemplateProductDefaultsArray(inner) : null, | |
| 303 | 405 | ) |
| 304 | 406 | } |
| 305 | 407 | |
| 408 | +/** Web 模板 DTO 的 templateProductDefaults 数组 → 扁平 elementId → value */ | |
| 409 | +function tryExtractFromTemplateProductDefaultsArray(layer: unknown): Record<string, string> | null { | |
| 410 | + if (layer == null || typeof layer !== 'object' || Array.isArray(layer)) return null | |
| 411 | + const L = layer as Record<string, unknown> | |
| 412 | + const raw = L.templateProductDefaults ?? L.TemplateProductDefaults | |
| 413 | + if (!Array.isArray(raw)) return null | |
| 414 | + const out: Record<string, string> = {} | |
| 415 | + for (const row of raw) { | |
| 416 | + if (row == null || typeof row !== 'object' || Array.isArray(row)) continue | |
| 417 | + const rec = row as Record<string, unknown> | |
| 418 | + const dv = rec.defaultValues ?? rec.DefaultValues | |
| 419 | + if (dv == null || typeof dv !== 'object' || Array.isArray(dv)) continue | |
| 420 | + for (const [k, v] of Object.entries(dv as Record<string, unknown>)) { | |
| 421 | + const key = String(k).trim() | |
| 422 | + if (!key) continue | |
| 423 | + out[key] = coerceTemplateProductDefaultValueToString(v) | |
| 424 | + } | |
| 425 | + } | |
| 426 | + return Object.keys(out).length ? out : null | |
| 427 | +} | |
| 428 | + | |
| 306 | 429 | function isTemplateSectionScanElement(el: SystemTemplateElementBase): boolean { |
| 307 | 430 | const cfg = el.config || {} |
| 308 | 431 | const typeAdd = String( |
| ... | ... | @@ -377,18 +500,41 @@ export function applyProductCodeValueToTemplateScanElements( |
| 377 | 500 | } |
| 378 | 501 | |
| 379 | 502 | /** |
| 503 | + * 从 templateProductDefaultValues 按 id / inputKey / elementName 匹配(大小写不敏感兜底)。 | |
| 504 | + */ | |
| 505 | +export function lookupTemplateProductDefaultValue( | |
| 506 | + el: SystemTemplateElementBase, | |
| 507 | + defaults: Record<string, string>, | |
| 508 | +): string | undefined { | |
| 509 | + const candidates = [ | |
| 510 | + String(el.id ?? '').trim(), | |
| 511 | + String(el.inputKey ?? '').trim(), | |
| 512 | + String(el.elementName ?? '').trim(), | |
| 513 | + ].filter(Boolean) | |
| 514 | + for (const k of candidates) { | |
| 515 | + if (Object.prototype.hasOwnProperty.call(defaults, k)) return defaults[k] | |
| 516 | + } | |
| 517 | + const lowerEntries = Object.entries(defaults).map(([k, v]) => [k.toLowerCase(), v] as const) | |
| 518 | + for (const k of candidates) { | |
| 519 | + const lk = k.toLowerCase() | |
| 520 | + const hit = lowerEntries.find(([dk]) => dk === lk) | |
| 521 | + if (hit) return hit[1] | |
| 522 | + } | |
| 523 | + return undefined | |
| 524 | +} | |
| 525 | + | |
| 526 | +/** | |
| 380 | 527 | * 将平台录入的默认值合并进模板元素 config,供画布预览(键与 elements[].id 一致)。 |
| 381 | 528 | */ |
| 382 | 529 | export function applyTemplateProductDefaultValuesToTemplate( |
| 383 | 530 | template: SystemLabelTemplate, |
| 384 | - defaults: Record<string, string> | |
| 531 | + defaults: Record<string, string>, | |
| 532 | + base: Date = new Date(), | |
| 385 | 533 | ): SystemLabelTemplate { |
| 386 | 534 | const keys = Object.keys(defaults) |
| 387 | 535 | if (!keys.length) return template |
| 388 | 536 | const elements = (template.elements || []).map((el) => { |
| 389 | - const byName = (el.elementName ?? '').trim() | |
| 390 | - const v = | |
| 391 | - defaults[el.id] ?? (byName ? defaults[byName] : undefined) | |
| 537 | + const v = lookupTemplateProductDefaultValue(el, defaults) | |
| 392 | 538 | if (v === undefined) return el |
| 393 | 539 | const type = String(el.type || '').toUpperCase() |
| 394 | 540 | const cfg = { ...(el.config || {}) } as Record<string, any> |
| ... | ... | @@ -422,28 +568,45 @@ export function applyTemplateProductDefaultValuesToTemplate( |
| 422 | 568 | } |
| 423 | 569 | |
| 424 | 570 | if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { |
| 425 | - const text = resolveTemplateDefaultValueForElement(el, v, new Date()) | |
| 571 | + captureDateDisplayFormatInConfig(cfg) | |
| 572 | + sanitizeDateElementConfig(el, cfg) | |
| 573 | + /** 偏移类字段只保留 JSON / 纯数字,展示由 applyLiveDateTimeFieldsToTemplate 按 format 重算 */ | |
| 574 | + if (isUniAppDateTimeOffsetField(el)) { | |
| 575 | + const trimmed = String(v).trim() | |
| 576 | + if (parseStoredPrintInputOffset(trimmed) || /^\d+$/.test(trimmed)) { | |
| 577 | + cfg.text = trimmed | |
| 578 | + cfg.Text = trimmed | |
| 579 | + } | |
| 580 | + return { ...el, config: cfg } | |
| 581 | + } | |
| 582 | + const text = resolveTemplateDefaultValueForElement(el, v, base) | |
| 426 | 583 | cfg.text = text |
| 427 | 584 | cfg.Text = text |
| 428 | 585 | return { ...el, config: cfg } |
| 429 | 586 | } |
| 430 | 587 | |
| 431 | 588 | if (type === 'NUTRITION') { |
| 589 | + const manual = collectNutritionManualFromDefaults(el, defaults) | |
| 590 | + if (Object.keys(manual).some((k) => String(manual[k] ?? '').trim() !== '')) { | |
| 591 | + const merged = applyNutritionDefaultJsonToConfig(cfg, JSON.stringify(manual)) | |
| 592 | + return { ...el, config: merged } | |
| 593 | + } | |
| 432 | 594 | const s = String(v).trim() |
| 433 | 595 | if (s.startsWith('{')) { |
| 434 | 596 | const merged = applyNutritionDefaultJsonToConfig(cfg, s) |
| 435 | 597 | return { ...el, config: merged } |
| 436 | 598 | } |
| 437 | - cfg.text = s | |
| 438 | - cfg.Text = s | |
| 439 | - return { ...el, config: cfg } | |
| 599 | + return el | |
| 440 | 600 | } |
| 441 | 601 | |
| 442 | 602 | cfg.text = v |
| 443 | 603 | cfg.Text = v |
| 444 | 604 | return { ...el, config: cfg } |
| 445 | 605 | }) |
| 446 | - return { ...template, elements } | |
| 606 | + return hydrateNutritionElementsInTemplate( | |
| 607 | + applyLiveDateTimeFieldsToTemplate({ ...template, elements }, base), | |
| 608 | + defaults, | |
| 609 | + ) | |
| 447 | 610 | } |
| 448 | 611 | |
| 449 | 612 | function elementArrayLength (o: Record<string, unknown>): number { |
| ... | ... | @@ -490,6 +653,11 @@ function pickTemplateRootRecord (payload: Record<string, unknown>): Record<strin |
| 490 | 653 | return payload |
| 491 | 654 | } |
| 492 | 655 | |
| 656 | +export function normalizeTemplatePrintOrientation(raw: unknown): 'horizontal' | 'vertical' { | |
| 657 | + const v = String(raw ?? '').trim().toLowerCase() | |
| 658 | + return v === 'horizontal' ? 'horizontal' : 'vertical' | |
| 659 | +} | |
| 660 | + | |
| 493 | 661 | /** |
| 494 | 662 | * 将接口 8.2 返回的 template(或整段 DTO)规范为 SystemLabelTemplate,供打印适配器与预览画布使用。 |
| 495 | 663 | */ |
| ... | ... | @@ -510,26 +678,33 @@ export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLa |
| 510 | 678 | e.config ?? e.Config ?? e.ConfigJson ?? e.configJson ?? e.ConfigString, |
| 511 | 679 | ) |
| 512 | 680 | cfg = mergeFlatElementFieldsIntoConfig(e, cfg) |
| 513 | - const type = String(e.type ?? e.elementType ?? e.ElementType ?? 'TEXT_STATIC') | |
| 681 | + captureDateDisplayFormatInConfig(cfg) | |
| 682 | + const type = canonicalElementType(String(e.type ?? e.elementType ?? e.ElementType ?? 'TEXT_STATIC')) | |
| 514 | 683 | const vst = e.valueSourceType ?? e.ValueSourceType |
| 515 | 684 | const ik = e.inputKey ?? e.InputKey |
| 516 | 685 | const en = e.elementName ?? e.ElementName |
| 517 | - return { | |
| 686 | + const typeAddRaw = e.typeAdd ?? e.TypeAdd | |
| 687 | + const baseEl = { | |
| 518 | 688 | id: String(e.id ?? e.Id ?? `el-${index}`), |
| 519 | 689 | type, |
| 520 | 690 | x: Number(e.x ?? e.posX ?? e.PosX ?? 0), |
| 521 | 691 | y: Number(e.y ?? e.posY ?? e.PosY ?? 0), |
| 522 | 692 | width: Number(e.width ?? e.Width ?? 0), |
| 523 | 693 | height: Number(e.height ?? e.Height ?? 0), |
| 524 | - rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical', | |
| 525 | - border: String(e.border ?? e.BorderType ?? e.borderType ?? 'none'), | |
| 694 | + rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical' | 'left' | 'right' | 'rotate180', | |
| 695 | + border: String(e.border ?? e.Border ?? e.BorderType ?? e.borderType ?? 'none'), | |
| 526 | 696 | config: cfg as Record<string, any>, |
| 527 | 697 | zIndex: Number(e.zIndex ?? e.ZIndex ?? 0), |
| 528 | 698 | orderNum: Number(e.orderNum ?? e.OrderNum ?? index), |
| 529 | 699 | valueSourceType: vst != null ? String(vst) : undefined, |
| 530 | 700 | inputKey: ik != null && String(ik).trim() !== '' ? String(ik).trim() : undefined, |
| 531 | 701 | elementName: en != null && String(en).trim() !== '' ? String(en).trim() : undefined, |
| 532 | - } as SystemTemplateElementBase & { zIndex: number; orderNum: number } | |
| 702 | + } as SystemTemplateElementBase & { zIndex: number; orderNum: number; typeAdd?: string } | |
| 703 | + if (typeAddRaw != null && String(typeAddRaw).trim() !== '') { | |
| 704 | + baseEl.typeAdd = String(typeAddRaw).trim() | |
| 705 | + } | |
| 706 | + sanitizeDateElementConfig(baseEl, cfg as Record<string, unknown>) | |
| 707 | + return baseEl | |
| 533 | 708 | }) |
| 534 | 709 | |
| 535 | 710 | const unitRaw = String(t.unit ?? t.Unit ?? 'inch').toLowerCase() |
| ... | ... | @@ -539,7 +714,7 @@ export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLa |
| 539 | 714 | | 'cm' |
| 540 | 715 | | 'px' |
| 541 | 716 | |
| 542 | - return { | |
| 717 | + const tmpl: SystemLabelTemplate = { | |
| 543 | 718 | id: String(t.id ?? t.Id ?? 'preview'), |
| 544 | 719 | name: String(t.name ?? t.Name ?? 'Label'), |
| 545 | 720 | labelType: String(t.labelType ?? t.LabelType ?? ''), |
| ... | ... | @@ -549,8 +724,13 @@ export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLa |
| 549 | 724 | appliedLocation: String(t.appliedLocation ?? t.AppliedLocation ?? 'ALL'), |
| 550 | 725 | showRuler: !!(t.showRuler ?? t.ShowRuler), |
| 551 | 726 | showGrid: !!(t.showGrid ?? t.ShowGrid), |
| 727 | + border: String(t.border ?? t.Border ?? t.BorderType ?? t.borderType ?? 'none'), | |
| 728 | + printOrientation: normalizeTemplatePrintOrientation( | |
| 729 | + t.printOrientation ?? t.PrintOrientation, | |
| 730 | + ), | |
| 552 | 731 | elements, |
| 553 | 732 | } |
| 733 | + return hydrateNutritionElementsInTemplate(tmpl, {}) | |
| 554 | 734 | } |
| 555 | 735 | |
| 556 | 736 | export function sortElementsForPreview( | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/nutritionDefaultsMerge.ts
| 1 | 1 | /** |
| 2 | 2 | * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。 |
| 3 | + * `<` 前缀由模板 config 决定,录入 JSON 不包含 LessThan 字段。 | |
| 3 | 4 | */ |
| 5 | +import type { SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' | |
| 6 | +import { NUTRITION_FACTS_LAYOUT_ROWS, buildNutritionFactsViewModel, NUTRITION_BODY_FONT_SIZE } from '../nutritionFactsLayout' | |
| 7 | + | |
| 8 | +/** 与 Web nutritionManualEntry.NUTRITION_FIELD_COMPOSITE_SEP 一致 */ | |
| 9 | +export const NUTRITION_FIELD_COMPOSITE_SEP = '###nut###' | |
| 10 | + | |
| 11 | +export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: string): string { | |
| 12 | + return `${nutritionElementId}${NUTRITION_FIELD_COMPOSITE_SEP}${subKey}` | |
| 13 | +} | |
| 14 | + | |
| 15 | +function defaultLookupCandidates(el: SystemTemplateElementBase): string[] { | |
| 16 | + return [ | |
| 17 | + String(el.id ?? '').trim(), | |
| 18 | + String(el.inputKey ?? '').trim(), | |
| 19 | + String(el.elementName ?? '').trim(), | |
| 20 | + ].filter(Boolean) | |
| 21 | +} | |
| 22 | + | |
| 23 | +/** 从 templateProductDefaultValues 解析营养成分手动录入(JSON 或 composite 列键) */ | |
| 24 | +export function collectNutritionManualFromDefaults( | |
| 25 | + el: SystemTemplateElementBase, | |
| 26 | + defaults: Record<string, string>, | |
| 27 | +): Record<string, string> { | |
| 28 | + if (!defaults || !Object.keys(defaults).length) return {} | |
| 29 | + const candidates = defaultLookupCandidates(el) | |
| 30 | + for (const k of candidates) { | |
| 31 | + if (Object.prototype.hasOwnProperty.call(defaults, k)) { | |
| 32 | + const raw = String(defaults[k] ?? '').trim() | |
| 33 | + if (raw.startsWith('{')) { | |
| 34 | + try { | |
| 35 | + const p = JSON.parse(raw) as Record<string, string> | |
| 36 | + if (p && typeof p === 'object' && !Array.isArray(p)) return p | |
| 37 | + } catch { | |
| 38 | + /* ignore */ | |
| 39 | + } | |
| 40 | + } | |
| 41 | + } | |
| 42 | + } | |
| 43 | + const lower = Object.entries(defaults).map(([k, v]) => [k.toLowerCase(), v] as const) | |
| 44 | + for (const k of candidates) { | |
| 45 | + const hit = lower.find(([dk]) => dk === k.toLowerCase()) | |
| 46 | + if (hit) { | |
| 47 | + const raw = String(hit[1] ?? '').trim() | |
| 48 | + if (raw.startsWith('{')) { | |
| 49 | + try { | |
| 50 | + return JSON.parse(raw) as Record<string, string> | |
| 51 | + } catch { | |
| 52 | + /* ignore */ | |
| 53 | + } | |
| 54 | + } | |
| 55 | + } | |
| 56 | + } | |
| 57 | + const id = String(el.id ?? '').trim() | |
| 58 | + if (!id) return {} | |
| 59 | + const prefix = `${id}${NUTRITION_FIELD_COMPOSITE_SEP}` | |
| 60 | + const manual: Record<string, string> = {} | |
| 61 | + for (const [k, v] of Object.entries(defaults)) { | |
| 62 | + if (k.startsWith(prefix)) manual[k.slice(prefix.length)] = String(v ?? '') | |
| 63 | + } | |
| 64 | + return manual | |
| 65 | +} | |
| 66 | + | |
| 67 | +function parseNutritionJsonFromConfigText(cfg: Record<string, unknown>): string | null { | |
| 68 | + const text = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 69 | + return text.startsWith('{') ? text : null | |
| 70 | +} | |
| 71 | + | |
| 72 | +/** 接口 8.2 会把营养成分 JSON 误写入 config.text;解析进 fixedNutrients / calories 等字段 */ | |
| 73 | +export function hydrateNutritionElementsInTemplate( | |
| 74 | + template: SystemLabelTemplate, | |
| 75 | + defaults: Record<string, string> = {}, | |
| 76 | +): SystemLabelTemplate { | |
| 77 | + const elements = (template.elements || []).map((el) => { | |
| 78 | + if (String(el.type || '').toUpperCase() !== 'NUTRITION') return el | |
| 79 | + let cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 80 | + const fromText = parseNutritionJsonFromConfigText(cfg) | |
| 81 | + if (fromText) { | |
| 82 | + cfg = applyNutritionDefaultJsonToConfig(cfg, fromText) | |
| 83 | + } | |
| 84 | + const manual = collectNutritionManualFromDefaults(el, defaults) | |
| 85 | + if (Object.keys(manual).some((k) => String(manual[k] ?? '').trim() !== '')) { | |
| 86 | + cfg = applyNutritionDefaultJsonToConfig(cfg, JSON.stringify(manual)) | |
| 87 | + } | |
| 88 | + if (fromText) { | |
| 89 | + delete cfg.text | |
| 90 | + delete cfg.Text | |
| 91 | + } | |
| 92 | + return { ...el, config: cfg } | |
| 93 | + }) | |
| 94 | + return { ...template, elements } | |
| 95 | +} | |
| 96 | + | |
| 97 | +/** 按实际行数估算营养表所需高度,避免矮框裁掉 Vitamin D / 页脚等行 */ | |
| 98 | +export function estimateNutritionPanelHeightPx(cfg: Record<string, unknown>): number { | |
| 99 | + const model = buildNutritionFactsViewModel(cfg) | |
| 100 | + const bodySize = NUTRITION_BODY_FONT_SIZE | |
| 101 | + const titleSize = Math.max(11, Math.min(18, model.titleFontSize)) | |
| 102 | + const lh = (fs: number) => fs + 4 | |
| 103 | + let y = 12 | |
| 104 | + y += titleSize + 6 | |
| 105 | + y += lh(bodySize) + 3 + lh(bodySize) + 5 | |
| 106 | + y += bodySize + 8 | |
| 107 | + for (const row of model.rows) { | |
| 108 | + y += lh(bodySize) | |
| 109 | + if (row.dividerAfter === 'double') y += 5 | |
| 110 | + else if (row.dividerAfter === 'thin') y += 3 | |
| 111 | + } | |
| 112 | + y += 24 | |
| 113 | + return Math.ceil(y) | |
| 114 | +} | |
| 115 | + | |
| 116 | +/** | |
| 117 | + * 仅当营养表增高时,把「原底边以下」的元素整体下移相同 delta,保持同行左右布局不变。 | |
| 118 | + * 禁止按 cursor 重排,避免 Consume By 标签/日期、ALLERGENS 等被拆散叠在一起。 | |
| 119 | + */ | |
| 120 | +export function reflowElementsBelowNutritionPanel( | |
| 121 | + template: SystemLabelTemplate, | |
| 122 | + previousNutritionBottom?: number, | |
| 123 | +): SystemLabelTemplate { | |
| 124 | + const elements = template.elements || [] | |
| 125 | + const nutrition = elements.find((el) => String(el.type || '').toUpperCase() === 'NUTRITION') | |
| 126 | + if (!nutrition) return template | |
| 127 | + | |
| 128 | + const nutY = Number(nutrition.y) || 0 | |
| 129 | + const nutBottom = nutY + (Number(nutrition.height) || 0) | |
| 130 | + const oldBottom = previousNutritionBottom ?? nutBottom | |
| 131 | + const delta = nutBottom - oldBottom | |
| 132 | + if (delta <= 1) return template | |
| 133 | + | |
| 134 | + const threshold = oldBottom - 2 | |
| 135 | + const next = elements.map((el) => { | |
| 136 | + if (el.id === nutrition.id) return el | |
| 137 | + const y = Number(el.y) || 0 | |
| 138 | + if (y >= threshold) return { ...el, y: y + delta } | |
| 139 | + return el | |
| 140 | + }) | |
| 141 | + return { ...template, elements: next } | |
| 142 | +} | |
| 143 | + | |
| 144 | +/** 仅在内容确实超出模板框时微增高度,并整体下移其下元素(不重排同行) */ | |
| 145 | +export function expandNutritionElementsToFitContent(template: SystemLabelTemplate): SystemLabelTemplate { | |
| 146 | + const elements = [...(template.elements || [])] | |
| 147 | + const nutIdx = elements.findIndex((el) => String(el.type || '').toUpperCase() === 'NUTRITION') | |
| 148 | + if (nutIdx < 0) return template | |
| 149 | + | |
| 150 | + const nutrition = elements[nutIdx] | |
| 151 | + const nutY = Number(nutrition.y) || 0 | |
| 152 | + const oldH = Number(nutrition.height) || 0 | |
| 153 | + const oldBottom = nutY + oldH | |
| 154 | + const need = estimateNutritionPanelHeightPx((nutrition.config || {}) as Record<string, unknown>) | |
| 155 | + const maxH = Math.max(oldH, need) | |
| 156 | + if (maxH <= oldH + 2) return template | |
| 157 | + | |
| 158 | + elements[nutIdx] = { ...nutrition, height: maxH } | |
| 159 | + return reflowElementsBelowNutritionPanel({ ...template, elements }, oldBottom) | |
| 160 | +} | |
| 161 | + | |
| 162 | +function fixedLabelForKey(key: string): string { | |
| 163 | + const hit = NUTRITION_FACTS_LAYOUT_ROWS.find((x) => x.key === key) | |
| 164 | + return hit?.label ?? key | |
| 165 | +} | |
| 166 | + | |
| 167 | +function pickManual(manual: Record<string, string>, subKey: string): string { | |
| 168 | + return String(manual[subKey] ?? '').trim() | |
| 169 | +} | |
| 170 | + | |
| 4 | 171 | export function applyNutritionDefaultJsonToConfig( |
| 5 | 172 | baseCfg: Record<string, unknown>, |
| 6 | 173 | jsonStr: string, |
| 7 | 174 | ): Record<string, unknown> { |
| 8 | - const t = String(jsonStr ?? "").trim(); | |
| 9 | - if (!t.startsWith("{")) return baseCfg; | |
| 10 | - let manual: Record<string, string> = {}; | |
| 175 | + const t = String(jsonStr ?? '').trim() | |
| 176 | + if (!t.startsWith('{')) return baseCfg | |
| 177 | + let manual: Record<string, string> = {} | |
| 11 | 178 | try { |
| 12 | - manual = JSON.parse(t) as Record<string, string>; | |
| 179 | + manual = JSON.parse(t) as Record<string, string> | |
| 13 | 180 | } catch { |
| 14 | - return baseCfg; | |
| 15 | - } | |
| 16 | - const out: Record<string, unknown> = { ...baseCfg }; | |
| 17 | - for (const [k, val] of Object.entries(manual)) { | |
| 18 | - const v = String(val ?? "").trim(); | |
| 19 | - if (k === "calories") { | |
| 20 | - if (v) out.calories = v; | |
| 21 | - continue; | |
| 22 | - } | |
| 23 | - if (k === "servingsPerContainer") { | |
| 24 | - out.servingsPerContainer = v; | |
| 25 | - continue; | |
| 26 | - } | |
| 27 | - if (k === "servingSize") { | |
| 28 | - out.servingSize = v; | |
| 29 | - continue; | |
| 30 | - } | |
| 31 | - if (k.startsWith("extra:") && k.endsWith(":value")) { | |
| 32 | - const id = k.slice("extra:".length, -":value".length); | |
| 33 | - const arr = Array.isArray(out.extraNutrients) | |
| 34 | - ? ([...(out.extraNutrients as Record<string, unknown>[])]) | |
| 35 | - : []; | |
| 36 | - const idx = arr.findIndex((row) => String((row as any).id ?? "") === id); | |
| 37 | - if (idx >= 0) { | |
| 38 | - arr[idx] = { ...arr[idx], value: v }; | |
| 39 | - } | |
| 40 | - out.extraNutrients = arr; | |
| 41 | - continue; | |
| 42 | - } | |
| 43 | - const fr = Array.isArray(out.fixedNutrients) | |
| 44 | - ? ([...(out.fixedNutrients as Record<string, unknown>[])]) | |
| 45 | - : []; | |
| 46 | - const idx = fr.findIndex((row) => String((row as any).key ?? "").trim() === k); | |
| 47 | - if (idx >= 0) { | |
| 48 | - fr[idx] = { ...fr[idx], value: v }; | |
| 49 | - } else { | |
| 50 | - fr.push({ key: k, label: k, value: v, unit: "" }); | |
| 51 | - } | |
| 52 | - out.fixedNutrients = fr; | |
| 181 | + return baseCfg | |
| 182 | + } | |
| 183 | + | |
| 184 | + const out: Record<string, unknown> = { ...baseCfg } | |
| 185 | + const baseFixed = Array.isArray(baseCfg.fixedNutrients) | |
| 186 | + ? (baseCfg.fixedNutrients as Record<string, unknown>[]) | |
| 187 | + : [] | |
| 188 | + | |
| 189 | + const cal = pickManual(manual, 'calories') | |
| 190 | + if (cal) out.calories = cal | |
| 191 | + else { | |
| 192 | + delete out.calories | |
| 193 | + delete out.Calories | |
| 194 | + } | |
| 195 | + out.servingsPerContainer = pickManual(manual, 'servingsPerContainer') | |
| 196 | + out.servingSize = pickManual(manual, 'servingSize') | |
| 197 | + | |
| 198 | + const keysInOrder = [...NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key)] | |
| 199 | + for (const row of baseFixed) { | |
| 200 | + const key = String(row.key ?? '').trim() | |
| 201 | + if (key && !keysInOrder.includes(key)) keysInOrder.push(key) | |
| 202 | + } | |
| 203 | + | |
| 204 | + const fixedArr: Record<string, unknown>[] = [] | |
| 205 | + for (const key of keysInOrder) { | |
| 206 | + const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === key) | |
| 207 | + const layout = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key) | |
| 208 | + const v = pickManual(manual, key) | |
| 209 | + const pct = pickManual(manual, `${key}Percent`) | |
| 210 | + const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`]) | |
| 211 | + const label = String(baseRow?.label ?? layout?.label ?? fixedLabelForKey(key)) | |
| 212 | + fixedArr.push({ | |
| 213 | + key, | |
| 214 | + label, | |
| 215 | + value: v, | |
| 216 | + unit: '', | |
| 217 | + dailyValuePercent: pct, | |
| 218 | + lessThan, | |
| 219 | + }) | |
| 220 | + if (v) out[key] = v | |
| 221 | + else delete out[key] | |
| 222 | + delete out[`${key}Unit`] | |
| 223 | + out[`${key}Percent`] = pct | |
| 224 | + out[`${key}LessThan`] = lessThan | |
| 225 | + } | |
| 226 | + out.fixedNutrients = fixedArr | |
| 227 | + | |
| 228 | + const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = [] | |
| 229 | + for (const k of Object.keys(manual)) { | |
| 230 | + if (!k.startsWith('extra:') || !k.endsWith(':value')) continue | |
| 231 | + const id = k.slice('extra:'.length, -':value'.length) | |
| 232 | + newExtras.push({ | |
| 233 | + id, | |
| 234 | + name: String( | |
| 235 | + (Array.isArray(out.extraNutrients) | |
| 236 | + ? (out.extraNutrients as Record<string, unknown>[]).find((row) => String(row.id ?? '') === id) | |
| 237 | + : undefined)?.name ?? 'Other', | |
| 238 | + ), | |
| 239 | + value: pickManual(manual, k), | |
| 240 | + unit: '', | |
| 241 | + }) | |
| 242 | + out[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`) | |
| 243 | + out[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`]) | |
| 53 | 244 | } |
| 54 | - return out; | |
| 245 | + if (newExtras.length > 0) out.extraNutrients = newExtras | |
| 246 | + | |
| 247 | + delete out.ingredientsText | |
| 248 | + delete out.IngredientsText | |
| 249 | + return out | |
| 55 | 250 | } | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/printInputOffset.ts
| ... | ... | @@ -13,6 +13,268 @@ const OFFSET_UNITS = new Set([ |
| 13 | 13 | 'Years', |
| 14 | 14 | ]) |
| 15 | 15 | |
| 16 | +/** 与 Web PropertiesPanel DATE_FORMAT_OPTIONS + 常用 datetime 预设一致 */ | |
| 17 | +export const DATE_DISPLAY_FORMAT_PRESETS = new Set([ | |
| 18 | + 'DD/MM/YYYY', | |
| 19 | + 'MM/DD/YYYY', | |
| 20 | + 'DD/MM/YY', | |
| 21 | + 'MM/DD/YY', | |
| 22 | + 'MM/YY', | |
| 23 | + 'MM/DD', | |
| 24 | + 'MM', | |
| 25 | + 'DD', | |
| 26 | + 'YY', | |
| 27 | + 'FULLY DAY(WEDNESDAY)', | |
| 28 | + 'DAY (WED)', | |
| 29 | + 'MONTH (DECEMBER)', | |
| 30 | + 'YEAR (2025)', | |
| 31 | + 'DD MONTH YEAR (25 DECEMBER 2025)', | |
| 32 | + 'YYYY-MM-DD', | |
| 33 | + 'YYYY-MM-DD HH:mm', | |
| 34 | + 'HH:mm', | |
| 35 | + '12 hr', | |
| 36 | + '24 hr', | |
| 37 | +]) | |
| 38 | + | |
| 39 | +export function isKnownDateDisplayFormat (raw: string | null | undefined): boolean { | |
| 40 | + return DATE_DISPLAY_FORMAT_PRESETS.has(String(raw ?? '').trim()) | |
| 41 | +} | |
| 42 | + | |
| 43 | +export function isStoredPrintInputOffsetPayload (raw: string | null | undefined): boolean { | |
| 44 | + return parseStoredPrintInputOffset(String(raw ?? '').trim()) != null | |
| 45 | +} | |
| 46 | + | |
| 47 | +/** 自定义 datetime 模板(非预设名),不能作为展示 format */ | |
| 48 | +function looksLikeCustomDateTimeFormatTemplate (raw: string | null | undefined): boolean { | |
| 49 | + const t = String(raw ?? '').trim() | |
| 50 | + if (!t || isKnownDateDisplayFormat(t)) return false | |
| 51 | + if (/GMT/i.test(t)) return true | |
| 52 | + if (/\bCST\b|\bMT\+|\(CST\)/i.test(t)) return true | |
| 53 | + if (/HH:mm:ss/i.test(t)) return true | |
| 54 | + if (/:\d{2}:\d{2}/.test(t)) return true | |
| 55 | + if (/^\d{1,2}\s+\d{4}\s+\d{1,2}:\d{2}/.test(t)) return true | |
| 56 | + return false | |
| 57 | +} | |
| 58 | + | |
| 59 | +/** 已展开的日期/时间乱串(GMT 等);**不含**合法的偏移 JSON */ | |
| 60 | +export function isCorruptedDateDisplayFormat (raw: string | null | undefined): boolean { | |
| 61 | + const t = String(raw ?? '').trim() | |
| 62 | + if (!t) return false | |
| 63 | + if (isStoredPrintInputOffsetPayload(t)) return false | |
| 64 | + if (OFFSET_UNITS.has(t)) return true | |
| 65 | + if (looksLikeCustomDateTimeFormatTemplate(t)) return true | |
| 66 | + /** 如 06 2026 13:45:57 GMT+0 */ | |
| 67 | + if (/^\d{1,2}\s+\d{4}\s+\d{1,2}:\d{2}(:\d{2})?(\s+GMT)?/i.test(t)) return true | |
| 68 | + /** App 端 toLocaleString 失败时的 Date.toString():MON JUL 06 2026 14:29:07 GMT+0800 (CST) */ | |
| 69 | + if (/^(SUN|MON|TUE|WED|THU|FRI|SAT)\s+[A-Z]{3}\s+\d{1,2}\s+\d{4}/i.test(t)) return true | |
| 70 | + return false | |
| 71 | +} | |
| 72 | + | |
| 73 | +/** 与 normalizeLabelPanelTypeAdd 一致:优先元素 typeAdd,其次 config.typeAdd */ | |
| 74 | +export function resolveElementTypeAdd (el: SystemTemplateElementBase): string { | |
| 75 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 76 | + return String( | |
| 77 | + (el as { typeAdd?: string }).typeAdd ?? | |
| 78 | + cfg.typeAdd ?? | |
| 79 | + cfg.TypeAdd ?? | |
| 80 | + '', | |
| 81 | + ).trim() | |
| 82 | +} | |
| 83 | + | |
| 84 | +/** 元素命名/id hint(用于区分 durationdate / durationdate2) */ | |
| 85 | +export function elementDateNameHint (el: SystemTemplateElementBase): string { | |
| 86 | + return `${el.elementName ?? ''} ${el.inputKey ?? ''} ${el.id ?? ''}`.toLowerCase() | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** 底部黑条星期:仅 durationdate2(**不含** Use By 的 durationdate) */ | |
| 90 | +export function isWeekdayBarNameHint (hint: string): boolean { | |
| 91 | + return /durationdate2|duration_date_2|durationdate_2|duration\s*date\s*2/.test(hint) | |
| 92 | +} | |
| 93 | + | |
| 94 | +/** Use By / 到期日:durationdate(无 2)、use by 等 */ | |
| 95 | +export function isUseByDateNameHint (hint: string): boolean { | |
| 96 | + if (isWeekdayBarNameHint(hint)) return false | |
| 97 | + return ( | |
| 98 | + /(?:^|\s|_)durationdate(?:\s|_|$)/.test(hint) || | |
| 99 | + /duration\s*date(?!\s*2)/.test(hint) || | |
| 100 | + /duration_date(?!_2)/.test(hint) || | |
| 101 | + /use\s*by|must\s*use|expir/.test(hint) | |
| 102 | + ) | |
| 103 | +} | |
| 104 | + | |
| 105 | +function frozenKnownDateFormat (cfg: Record<string, unknown>): string { | |
| 106 | + const preserved = String(cfg.__dateDisplayFormat ?? cfg.__DateDisplayFormat ?? '').trim() | |
| 107 | + if (preserved && isKnownDateDisplayFormat(preserved)) return preserved | |
| 108 | + for (const c of [cfg.format, cfg.Format, cfg.displayFormat, cfg.DisplayFormat]) { | |
| 109 | + const raw = String(c ?? '').trim() | |
| 110 | + if (raw && isKnownDateDisplayFormat(raw) && !parseStoredPrintInputOffset(raw)) return raw | |
| 111 | + } | |
| 112 | + return '' | |
| 113 | +} | |
| 114 | + | |
| 115 | +/** 打印时输入的字面量字段(Number/Text/Weight),不得按日期偏移重算 */ | |
| 116 | +export function isPrintInputLiteralField (el: SystemTemplateElementBase): boolean { | |
| 117 | + const vst = String(el.valueSourceType || '').toUpperCase() | |
| 118 | + if (vst !== 'PRINT_INPUT') return false | |
| 119 | + const rawType = String(el.type || '').trim() | |
| 120 | + const typeUpper = rawType.toUpperCase() | |
| 121 | + if (typeUpper === 'WEIGHT' || typeUpper.includes('|WEIGHT')) return true | |
| 122 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 123 | + const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() | |
| 124 | + if (it === 'number' || it === 'text') return true | |
| 125 | + const ta = resolveElementTypeAdd(el).toLowerCase() | |
| 126 | + if (/\|\s*number\b/.test(ta) || ta.endsWith('number')) return true | |
| 127 | + if (/\|\s*weight\b/.test(ta) || ta.endsWith('weight')) return true | |
| 128 | + if (/\|\s*text\b/.test(ta) || ta.endsWith('|text')) return true | |
| 129 | + if (/print[\s|]*number/i.test(rawType)) return true | |
| 130 | + if (/print[\s|]*text/i.test(rawType)) return true | |
| 131 | + if (/print[\s|]*weight/i.test(rawType)) return true | |
| 132 | + return false | |
| 133 | +} | |
| 134 | + | |
| 135 | +/** 打印时输入 Number/Text/Weight 的展示文案(与 Web 画布 print|number 一致) */ | |
| 136 | +export function resolvePrintInputLiteralDisplay (el: SystemTemplateElementBase): string { | |
| 137 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 138 | + const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() | |
| 139 | + const type = String(el.type || '').toUpperCase() | |
| 140 | + let body = '' | |
| 141 | + if (type === 'WEIGHT') { | |
| 142 | + body = String(cfg.value ?? cfg.Value ?? '').trim() | |
| 143 | + if (!body) body = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 144 | + } else { | |
| 145 | + body = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 146 | + } | |
| 147 | + if (!body && it === 'number') body = '0' | |
| 148 | + /** 字面量 Number/Text 的纯数字不能走偏移 JSON 解析(否则会误当成 Days 偏移而清空) */ | |
| 149 | + if (isCorruptedDateDisplayFormat(body)) return '' | |
| 150 | + const unit = String(cfg.unit ?? cfg.Unit ?? '').trim() | |
| 151 | + if (unit && body && !body.endsWith(unit)) body = `${body}${unit}` | |
| 152 | + return applyElementPrefix(cfg, body) | |
| 153 | +} | |
| 154 | + | |
| 155 | +/** Text (For Template) 等固定文案:invertColors 仅表示黑底白字,不是星期条 */ | |
| 156 | +export function isFixedStaticTextElement (el: SystemTemplateElementBase): boolean { | |
| 157 | + if (isPrintInputLiteralField(el)) return true | |
| 158 | + const type = String(el.type || '').toUpperCase() | |
| 159 | + if (type !== 'TEXT_STATIC' && type !== 'TEXT' && type !== 'TEXT_PRODUCT' && type !== 'TEXT_PRICE') { | |
| 160 | + return false | |
| 161 | + } | |
| 162 | + const vst = String(el.valueSourceType || '').toUpperCase() | |
| 163 | + if (vst && vst !== 'FIXED' && vst !== 'STATIC') return false | |
| 164 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 165 | + const inputType = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() | |
| 166 | + if ( | |
| 167 | + inputType === 'date' || | |
| 168 | + inputType === 'datetime' || | |
| 169 | + inputType === 'number' || | |
| 170 | + inputType === 'options' | |
| 171 | + ) { | |
| 172 | + return false | |
| 173 | + } | |
| 174 | + if (isUniAppDateTimeOffsetField(el)) return false | |
| 175 | + if (isWeekdayBarNameHint(elementDateNameHint(el))) return false | |
| 176 | + return true | |
| 177 | +} | |
| 178 | + | |
| 179 | +/** | |
| 180 | + * 底部黑条星期元素(durationdate2 / FULLY DAY 格式)。 | |
| 181 | + * invertColors 只负责黑底白字样式,不能单独判定为星期条。 | |
| 182 | + */ | |
| 183 | +export function isWeekdayBarElement (el: SystemTemplateElementBase): boolean { | |
| 184 | + const type = String(el.type || '').toUpperCase() | |
| 185 | + /** 重打已焙成 TEXT_STATIC 的星期条不再走 live 偏移,直接画 config.text */ | |
| 186 | + if (type === 'TEXT_STATIC' || type === 'TEXT') return false | |
| 187 | + if (isFixedStaticTextElement(el)) return false | |
| 188 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 189 | + const hint = elementDateNameHint(el) | |
| 190 | + if (isWeekdayBarNameHint(hint)) return true | |
| 191 | + const fmt = frozenKnownDateFormat(cfg) | |
| 192 | + if (fmt === 'FULLY DAY(WEDNESDAY)' || fmt === 'DAY (WED)') return true | |
| 193 | + return false | |
| 194 | +} | |
| 195 | + | |
| 196 | +/** @deprecated 使用 isWeekdayBarElement */ | |
| 197 | +export function isInvertedWeekdayElement (el: SystemTemplateElementBase): boolean { | |
| 198 | + return isWeekdayBarElement(el) | |
| 199 | +} | |
| 200 | + | |
| 201 | +/** 首次解析模板时冻结展示用 format,避免后续 merge 把 offset JSON / 展开串写进 format 后丢失 FULLY DAY 等 */ | |
| 202 | +export function captureDateDisplayFormatInConfig (cfg: Record<string, unknown>): void { | |
| 203 | + if (String(cfg.__dateDisplayFormat ?? cfg.__DateDisplayFormat ?? '').trim()) return | |
| 204 | + for (const c of [cfg.format, cfg.Format, cfg.displayFormat, cfg.DisplayFormat]) { | |
| 205 | + const raw = String(c ?? '').trim() | |
| 206 | + if (!raw || parseStoredPrintInputOffset(raw)) continue | |
| 207 | + if (!isKnownDateDisplayFormat(raw) || isCorruptedDateDisplayFormat(raw)) continue | |
| 208 | + cfg.__dateDisplayFormat = raw | |
| 209 | + return | |
| 210 | + } | |
| 211 | +} | |
| 212 | + | |
| 213 | +/** 清理被污染的 format/text,恢复 FULLY DAY 等展示格式;保留合法偏移 JSON */ | |
| 214 | +export function sanitizeDateElementConfig ( | |
| 215 | + el: SystemTemplateElementBase, | |
| 216 | + cfg: Record<string, unknown>, | |
| 217 | +): void { | |
| 218 | + if (isFixedStaticTextElement(el)) return | |
| 219 | + captureDateDisplayFormatInConfig(cfg) | |
| 220 | + const preserved = String(cfg.__dateDisplayFormat ?? cfg.__DateDisplayFormat ?? '').trim() | |
| 221 | + let fmt = String(cfg.format ?? cfg.Format ?? '').trim() | |
| 222 | + let txt = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 223 | + const fmtOffset = isStoredPrintInputOffsetPayload(fmt) | |
| 224 | + const txtOffset = isStoredPrintInputOffsetPayload(txt) | |
| 225 | + | |
| 226 | + /** 后端 PRINT_INPUT 可能把偏移 JSON 写进 format;统一迁到 text 并恢复展示 format */ | |
| 227 | + if (fmtOffset && !txtOffset) { | |
| 228 | + cfg.text = fmt | |
| 229 | + cfg.Text = fmt | |
| 230 | + txt = fmt | |
| 231 | + } | |
| 232 | + | |
| 233 | + if (fmtOffset || txtOffset) { | |
| 234 | + const hint = elementDateNameHint(el) | |
| 235 | + const next = | |
| 236 | + (preserved && isKnownDateDisplayFormat(preserved) ? preserved : '') || | |
| 237 | + (isWeekdayBarNameHint(hint) ? 'FULLY DAY(WEDNESDAY)' : '') || | |
| 238 | + (isUseByDateNameHint(hint) ? 'MM/DD/YYYY' : '') || | |
| 239 | + 'DD/MM/YYYY' | |
| 240 | + if (isKnownDateDisplayFormat(next)) { | |
| 241 | + cfg.format = next | |
| 242 | + cfg.Format = next | |
| 243 | + if (!cfg.__dateDisplayFormat) cfg.__dateDisplayFormat = next | |
| 244 | + } | |
| 245 | + fmt = String(cfg.format ?? cfg.Format ?? '').trim() | |
| 246 | + } else if (fmt && (isCorruptedDateDisplayFormat(fmt) || looksLikeCustomDateTimeFormatTemplate(fmt))) { | |
| 247 | + if (isFixedStaticTextElement(el)) return | |
| 248 | + const hint = elementDateNameHint(el) | |
| 249 | + const next = | |
| 250 | + preserved || | |
| 251 | + (isWeekdayBarNameHint(hint) ? 'FULLY DAY(WEDNESDAY)' : '') || | |
| 252 | + (isUseByDateNameHint(hint) ? 'MM/DD/YYYY' : '') | |
| 253 | + if (next) { | |
| 254 | + cfg.format = next | |
| 255 | + cfg.Format = next | |
| 256 | + if (!cfg.__dateDisplayFormat) cfg.__dateDisplayFormat = next | |
| 257 | + } else { | |
| 258 | + delete cfg.format | |
| 259 | + delete cfg.Format | |
| 260 | + } | |
| 261 | + } | |
| 262 | + | |
| 263 | + const txtAfter = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 264 | + if (txtAfter && isCorruptedDateDisplayFormat(txtAfter)) { | |
| 265 | + const hint = elementDateNameHint(el) | |
| 266 | + if (isUniAppDateTimeOffsetField(el) || isWeekdayBarNameHint(hint) || isUseByDateNameHint(hint)) { | |
| 267 | + delete cfg.text | |
| 268 | + delete cfg.Text | |
| 269 | + } | |
| 270 | + } | |
| 271 | + const pf = String(cfg.__previewFormatted ?? cfg.__PreviewFormatted ?? '').trim() | |
| 272 | + if (pf && isCorruptedDateDisplayFormat(pf)) { | |
| 273 | + delete cfg.__previewFormatted | |
| 274 | + delete cfg.__PreviewFormatted | |
| 275 | + } | |
| 276 | +} | |
| 277 | + | |
| 16 | 278 | export function applyOffsetToDate (base: Date, amount: number, unit: string): Date { |
| 17 | 279 | const d = new Date(base.getTime()) |
| 18 | 280 | const u = String(unit ?? '').trim() |
| ... | ... | @@ -41,6 +303,70 @@ export function applyOffsetToDate (base: Date, amount: number, unit: string): Da |
| 41 | 303 | return d |
| 42 | 304 | } |
| 43 | 305 | |
| 306 | +/** App 原生端 toLocaleString(weekday) 常返回整段 Date.toString(),须用手动表 */ | |
| 307 | +const WEEKDAY_LONG_EN = [ | |
| 308 | + 'SUNDAY', | |
| 309 | + 'MONDAY', | |
| 310 | + 'TUESDAY', | |
| 311 | + 'WEDNESDAY', | |
| 312 | + 'THURSDAY', | |
| 313 | + 'FRIDAY', | |
| 314 | + 'SATURDAY', | |
| 315 | +] as const | |
| 316 | + | |
| 317 | +const WEEKDAY_SHORT_EN = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const | |
| 318 | + | |
| 319 | +/** 已是展开后的星期展示文案(重打快照 / 落库 __previewFormatted) */ | |
| 320 | +export function isResolvedWeekdayDisplayLiteral (raw: string | null | undefined): boolean { | |
| 321 | + const t = String(raw ?? '').trim().toUpperCase() | |
| 322 | + if (!t) return false | |
| 323 | + if ((WEEKDAY_LONG_EN as readonly string[]).includes(t)) return true | |
| 324 | + if ((WEEKDAY_SHORT_EN as readonly string[]).includes(t)) return true | |
| 325 | + if (/^DAY\s*\([A-Z]{3}\)$/i.test(String(raw ?? '').trim())) return true | |
| 326 | + return false | |
| 327 | +} | |
| 328 | + | |
| 329 | +/** 重打/画布:优先读快照里已冻结的星期文案,禁止再按 today 重算 */ | |
| 330 | +export function readFrozenWeekdayDisplayFromConfig (cfg: Record<string, unknown>): string { | |
| 331 | + for (const key of ['__previewFormatted', '__PreviewFormatted', 'text', 'Text'] as const) { | |
| 332 | + const raw = String(cfg[key] ?? '').trim() | |
| 333 | + if (!raw) continue | |
| 334 | + if (parseStoredPrintInputOffset(raw)) continue | |
| 335 | + if (isCorruptedDateDisplayFormat(raw)) continue | |
| 336 | + if (isResolvedWeekdayDisplayLiteral(raw)) { | |
| 337 | + return raw.toUpperCase().startsWith('DAY (') ? raw : raw.toUpperCase() | |
| 338 | + } | |
| 339 | + } | |
| 340 | + return '' | |
| 341 | +} | |
| 342 | + | |
| 343 | +const MONTH_LONG_EN = [ | |
| 344 | + 'JANUARY', | |
| 345 | + 'FEBRUARY', | |
| 346 | + 'MARCH', | |
| 347 | + 'APRIL', | |
| 348 | + 'MAY', | |
| 349 | + 'JUNE', | |
| 350 | + 'JULY', | |
| 351 | + 'AUGUST', | |
| 352 | + 'SEPTEMBER', | |
| 353 | + 'OCTOBER', | |
| 354 | + 'NOVEMBER', | |
| 355 | + 'DECEMBER', | |
| 356 | +] as const | |
| 357 | + | |
| 358 | +function weekdayLongEn (date: Date): string { | |
| 359 | + return WEEKDAY_LONG_EN[date.getDay()] ?? 'SUNDAY' | |
| 360 | +} | |
| 361 | + | |
| 362 | +function weekdayShortEn (date: Date): string { | |
| 363 | + return WEEKDAY_SHORT_EN[date.getDay()] ?? 'SUN' | |
| 364 | +} | |
| 365 | + | |
| 366 | +function monthLongEn (date: Date): string { | |
| 367 | + return MONTH_LONG_EN[date.getMonth()] ?? 'JANUARY' | |
| 368 | +} | |
| 369 | + | |
| 44 | 370 | function formatDateByPreset (format: string, date: Date): string { |
| 45 | 371 | const yyyy = String(date.getFullYear()) |
| 46 | 372 | const yy = yyyy.slice(-2) |
| ... | ... | @@ -48,9 +374,11 @@ function formatDateByPreset (format: string, date: Date): string { |
| 48 | 374 | const dd = String(date.getDate()).padStart(2, '0') |
| 49 | 375 | const hh = String(date.getHours()).padStart(2, '0') |
| 50 | 376 | const min = String(date.getMinutes()).padStart(2, '0') |
| 51 | - const monthLong = date.toLocaleString('en-US', { month: 'long' }).toUpperCase() | |
| 52 | - const dayLong = date.toLocaleString('en-US', { weekday: 'long' }).toUpperCase() | |
| 53 | - const dayShort = date.toLocaleString('en-US', { weekday: 'short' }).toUpperCase() | |
| 377 | + const hour12 = date.getHours() % 12 || 12 | |
| 378 | + const ampm = date.getHours() >= 12 ? 'pm' : 'am' | |
| 379 | + const monthLong = monthLongEn(date) | |
| 380 | + const dayLong = weekdayLongEn(date) | |
| 381 | + const dayShort = weekdayShortEn(date) | |
| 54 | 382 | switch (format) { |
| 55 | 383 | case 'DD/MM/YYYY': |
| 56 | 384 | return `${dd}/${mm}/${yyyy}` |
| ... | ... | @@ -84,9 +412,15 @@ function formatDateByPreset (format: string, date: Date): string { |
| 84 | 412 | return `${yyyy}-${mm}-${dd}` |
| 85 | 413 | case 'YYYY-MM-DD HH:mm': |
| 86 | 414 | return `${yyyy}-${mm}-${dd} ${hh}:${min}` |
| 415 | + case '12 hr': | |
| 416 | + return `${hour12}:${min}${ampm}` | |
| 417 | + case '24 hr': | |
| 87 | 418 | case 'HH:mm': |
| 88 | 419 | return `${hh}:${min}` |
| 89 | 420 | default: |
| 421 | + if (isCorruptedDateDisplayFormat(format) || looksLikeCustomDateTimeFormatTemplate(format)) { | |
| 422 | + return `${mm}/${dd}/${yyyy}` | |
| 423 | + } | |
| 90 | 424 | return String(format || '') |
| 91 | 425 | .replace(/YYYY/g, yyyy) |
| 92 | 426 | .replace(/YY/g, yy) |
| ... | ... | @@ -105,7 +439,7 @@ export function isLikelyResolvedDateTimeLiteral (raw: string | null | undefined) |
| 105 | 439 | if (/^\d{4}-\d{2}-\d{2}/.test(t)) return true |
| 106 | 440 | if (/^\d{4}-\d{2}-\d{2}\s+\d{1,2}:\d{2}/.test(t)) return true |
| 107 | 441 | if (/^\d{1,2}\/\d{1,2}\/\d{2,4}/.test(t)) return true |
| 108 | - if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(t)) return true | |
| 442 | + if (/^\d{1,2}:\d{2}(:\d{2})?(\s?[ap]m)?$/i.test(t)) return true | |
| 109 | 443 | return false |
| 110 | 444 | } |
| 111 | 445 | |
| ... | ... | @@ -123,25 +457,59 @@ function normalizeOffsetAmount (valueRaw: string): number | null { |
| 123 | 457 | return Number.isFinite(n) ? n : null |
| 124 | 458 | } |
| 125 | 459 | |
| 460 | +function inferDurationWeekdayDisplayFormat (el: SystemTemplateElementBase, cfg: Record<string, unknown>): string | null { | |
| 461 | + if (!isWeekdayBarElement({ ...el, config: cfg })) return null | |
| 462 | + const preserved = String(cfg.__dateDisplayFormat ?? cfg.__DateDisplayFormat ?? '').trim() | |
| 463 | + if (preserved === 'FULLY DAY(WEDNESDAY)' || preserved === 'DAY (WED)') return preserved | |
| 464 | + return 'FULLY DAY(WEDNESDAY)' | |
| 465 | +} | |
| 466 | + | |
| 126 | 467 | function dateFormatPatternForElement ( |
| 127 | 468 | el: SystemTemplateElementBase, |
| 128 | 469 | cfg: Record<string, unknown>, |
| 129 | 470 | ): string { |
| 130 | 471 | const type = String(el.type || '').toUpperCase() |
| 131 | - if (type === 'TIME') return 'HH:mm' | |
| 472 | + if (type === 'TIME') { | |
| 473 | + const raw = String(cfg.format ?? cfg.Format ?? '24 hr').trim() | |
| 474 | + return raw === '12 hr' ? '12 hr' : '24 hr' | |
| 475 | + } | |
| 476 | + | |
| 477 | + /** 仅底部黑条星期走 FULLY DAY;Use By 仍用 MM/DD/YYYY */ | |
| 478 | + if (isWeekdayBarElement({ ...el, config: cfg })) { | |
| 479 | + const preserved = String(cfg.__dateDisplayFormat ?? cfg.__DateDisplayFormat ?? '').trim() | |
| 480 | + if (preserved === 'FULLY DAY(WEDNESDAY)' || preserved === 'DAY (WED)') return preserved | |
| 481 | + return 'FULLY DAY(WEDNESDAY)' | |
| 482 | + } | |
| 483 | + | |
| 132 | 484 | const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() |
| 133 | - const raw = | |
| 134 | - (typeof cfg.format === 'string' && cfg.format.trim() | |
| 135 | - ? cfg.format | |
| 136 | - : typeof cfg.Format === 'string' && cfg.Format.trim() | |
| 137 | - ? cfg.Format | |
| 138 | - : it === 'datetime' | |
| 139 | - ? 'YYYY-MM-DD HH:mm' | |
| 140 | - : 'DD/MM/YYYY') || 'DD/MM/YYYY' | |
| 141 | - if (isLikelyResolvedDateTimeLiteral(raw) || OFFSET_UNITS.has(raw)) { | |
| 142 | - return it === 'datetime' ? 'YYYY-MM-DD HH:mm' : 'DD/MM/YYYY' | |
| 485 | + const preserved = String(cfg.__dateDisplayFormat ?? cfg.__DateDisplayFormat ?? '').trim() | |
| 486 | + if (preserved && isKnownDateDisplayFormat(preserved)) return preserved | |
| 487 | + | |
| 488 | + const hint = elementDateNameHint(el) | |
| 489 | + if (isUseByDateNameHint(hint)) return 'MM/DD/YYYY' | |
| 490 | + | |
| 491 | + const candidates = [ | |
| 492 | + cfg.format, | |
| 493 | + cfg.Format, | |
| 494 | + cfg.displayFormat, | |
| 495 | + cfg.DisplayFormat, | |
| 496 | + cfg.dateFormat, | |
| 497 | + cfg.DateFormat, | |
| 498 | + ] | |
| 499 | + .map((v) => (typeof v === 'string' ? v.trim() : '')) | |
| 500 | + .filter(Boolean) | |
| 501 | + | |
| 502 | + for (const raw of candidates) { | |
| 503 | + if (isStoredPrintInputOffsetPayload(raw)) continue | |
| 504 | + if (isCorruptedDateDisplayFormat(raw) || looksLikeCustomDateTimeFormatTemplate(raw)) continue | |
| 505 | + if (isLikelyResolvedDateTimeLiteral(raw) || OFFSET_UNITS.has(raw)) continue | |
| 506 | + if (isKnownDateDisplayFormat(raw)) return raw | |
| 143 | 507 | } |
| 144 | - return raw | |
| 508 | + | |
| 509 | + const inferred = inferDurationWeekdayDisplayFormat(el, cfg) | |
| 510 | + if (inferred) return inferred | |
| 511 | + | |
| 512 | + return it === 'datetime' ? 'YYYY-MM-DD HH:mm' : 'DD/MM/YYYY' | |
| 145 | 513 | } |
| 146 | 514 | |
| 147 | 515 | function readOffsetFromElementConfig ( |
| ... | ... | @@ -149,6 +517,20 @@ function readOffsetFromElementConfig ( |
| 149 | 517 | ): { amount: number; unit: string } | null { |
| 150 | 518 | const type = String(el.type || '').toUpperCase() |
| 151 | 519 | const cfg = (el.config || {}) as Record<string, unknown> |
| 520 | + const fromStoredText = parseStoredPrintInputOffset(String(cfg.text ?? cfg.Text ?? '')) | |
| 521 | + if (fromStoredText) { | |
| 522 | + const amount = normalizeOffsetAmount(fromStoredText.value) | |
| 523 | + if (amount !== null) { | |
| 524 | + return { amount, unit: fromStoredText.unit || 'Days' } | |
| 525 | + } | |
| 526 | + } | |
| 527 | + const fromStoredFormat = parseStoredPrintInputOffset(String(cfg.format ?? cfg.Format ?? '')) | |
| 528 | + if (fromStoredFormat) { | |
| 529 | + const amount = normalizeOffsetAmount(fromStoredFormat.value) | |
| 530 | + if (amount !== null) { | |
| 531 | + return { amount, unit: fromStoredFormat.unit || 'Days' } | |
| 532 | + } | |
| 533 | + } | |
| 152 | 534 | if (type === 'DURATION') { |
| 153 | 535 | const unit = String(cfg.format ?? cfg.Format ?? 'Days').trim() || 'Days' |
| 154 | 536 | const u = OFFSET_UNITS.has(unit) ? unit : 'Days' |
| ... | ... | @@ -189,7 +571,7 @@ function resolveFromOffsetAmount ( |
| 189 | 571 | return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d)) |
| 190 | 572 | } |
| 191 | 573 | if (type === 'TIME') { |
| 192 | - return applyElementPrefix(cfg, formatDateByPreset('HH:mm', d)) | |
| 574 | + return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d)) | |
| 193 | 575 | } |
| 194 | 576 | return applyElementPrefix(cfg, `${amount} ${unit}`.trim()) |
| 195 | 577 | } |
| ... | ... | @@ -204,13 +586,18 @@ export function resolveElementDateTimeDisplay ( |
| 204 | 586 | const type = String(el.type || '').toUpperCase() |
| 205 | 587 | if (type !== 'DATE' && type !== 'TIME' && type !== 'DURATION') return null |
| 206 | 588 | |
| 207 | - const cfg = (el.config || {}) as Record<string, unknown> | |
| 589 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 590 | + sanitizeDateElementConfig(el, cfg) | |
| 591 | + if (isPrintInputLiteralField({ ...el, config: cfg })) { | |
| 592 | + const line = resolvePrintInputLiteralDisplay({ ...el, config: cfg }) | |
| 593 | + return line || null | |
| 594 | + } | |
| 208 | 595 | const vst = String(el.valueSourceType ?? '').toUpperCase() |
| 209 | 596 | const inputType = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() |
| 210 | 597 | const rawText = String(cfg.text ?? cfg.Text ?? '').trim() |
| 211 | 598 | |
| 212 | 599 | const fromOffsetJson = (raw: string): string | null => { |
| 213 | - const p = tryParsePrintInputOffsetStored(raw) | |
| 600 | + const p = parseStoredPrintInputOffset(raw) | |
| 214 | 601 | if (!p) return null |
| 215 | 602 | const amount = normalizeOffsetAmount(p.value) |
| 216 | 603 | if (amount === null) return null |
| ... | ... | @@ -219,95 +606,129 @@ export function resolveElementDateTimeDisplay ( |
| 219 | 606 | |
| 220 | 607 | const jsonResolved = (rawText ? fromOffsetJson(rawText) : null) |
| 221 | 608 | ?? fromOffsetJson(String(cfg.format ?? cfg.Format ?? '')) |
| 222 | - if (jsonResolved) return jsonResolved | |
| 609 | + if (jsonResolved) { | |
| 610 | + if (isCorruptedDateDisplayFormat(jsonResolved)) { | |
| 611 | + if (isWeekdayBarElement({ ...el, config: cfg })) { | |
| 612 | + return resolveWeekdayDisplayForElement({ ...el, config: cfg }, base) | |
| 613 | + } | |
| 614 | + return null | |
| 615 | + } | |
| 616 | + return jsonResolved | |
| 617 | + } | |
| 618 | + | |
| 619 | + const isOffsetField = isUniAppDateTimeOffsetField({ ...el, config: cfg }) | |
| 223 | 620 | |
| 224 | 621 | if (vst === 'PRINT_INPUT') { |
| 225 | - /** 保质期/Prep 等相对偏移字段:须按 base 重算,勿用接口首屏写入的过期 literal */ | |
| 622 | + /** 非相对偏移的 PRINT_INPUT 才使用固定文案;勿把已展开的 GMT/时间串当最终展示 */ | |
| 226 | 623 | if ( |
| 227 | 624 | rawText && |
| 228 | - !tryParsePrintInputOffsetStored(rawText) && | |
| 229 | - !isUniAppDateTimeOffsetField(el) | |
| 625 | + !parseStoredPrintInputOffset(rawText) && | |
| 626 | + !isOffsetField && | |
| 627 | + isLikelyResolvedDateTimeLiteral(rawText) && | |
| 628 | + !isCorruptedDateDisplayFormat(rawText) | |
| 230 | 629 | ) { |
| 231 | 630 | return applyElementPrefix(cfg, rawText) |
| 232 | 631 | } |
| 233 | 632 | if ( |
| 234 | - isUniAppDateTimeOffsetField(el) || | |
| 633 | + isOffsetField || | |
| 235 | 634 | inputType === 'date' || |
| 236 | 635 | inputType === 'datetime' || |
| 237 | 636 | type === 'TIME' |
| 238 | 637 | ) { |
| 239 | - const off = readOffsetFromElementConfig(el) | |
| 240 | - if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base) | |
| 241 | - /** 默认值已展开为字面日期时仍保留,避免丢失 durationdate 等 +N Days */ | |
| 242 | - if ( | |
| 243 | - rawText && | |
| 244 | - !tryParsePrintInputOffsetStored(rawText) && | |
| 245 | - isLikelyResolvedDateTimeLiteral(rawText) && | |
| 246 | - !isUniAppDateTimeOffsetField(el) | |
| 247 | - ) { | |
| 248 | - return applyElementPrefix(cfg, rawText) | |
| 249 | - } | |
| 250 | - return resolveFromOffsetAmount(el, 0, 'Days', base) | |
| 638 | + const off = readOffsetFromElementConfig({ ...el, config: cfg }) | |
| 639 | + if (off) return resolveFromOffsetAmount({ ...el, config: cfg }, off.amount, off.unit, base) | |
| 640 | + return resolveFromOffsetAmount({ ...el, config: cfg }, 0, 'Days', base) | |
| 251 | 641 | } |
| 252 | 642 | return null |
| 253 | 643 | } |
| 254 | 644 | |
| 255 | - /** FIXED 的 durationdate/durationtime 等:config.text 可能已是平台录入的偏移 JSON */ | |
| 256 | - if (vst === 'FIXED' && isUniAppDateTimeOffsetField(el)) { | |
| 257 | - const off = readOffsetFromElementConfig(el) | |
| 258 | - if (off && (off.amount !== 0 || rawText)) { | |
| 259 | - return resolveFromOffsetAmount(el, off.amount, off.unit, base) | |
| 260 | - } | |
| 261 | - if ( | |
| 262 | - rawText && | |
| 263 | - !tryParsePrintInputOffsetStored(rawText) && | |
| 264 | - isLikelyResolvedDateTimeLiteral(rawText) | |
| 265 | - ) { | |
| 266 | - return applyElementPrefix(cfg, rawText) | |
| 267 | - } | |
| 268 | - return resolveFromOffsetAmount(el, 0, 'Days', base) | |
| 645 | + /** FIXED 的 durationdate/durationtime 等:config.text 可能已是平台录入的偏移 JSON / 纯数字 */ | |
| 646 | + if (vst === 'FIXED' && isOffsetField) { | |
| 647 | + const off = readOffsetFromElementConfig({ ...el, config: cfg }) | |
| 648 | + if (off) return resolveFromOffsetAmount({ ...el, config: cfg }, off.amount, off.unit, base) | |
| 649 | + return resolveFromOffsetAmount({ ...el, config: cfg }, 0, 'Days', base) | |
| 269 | 650 | } |
| 270 | 651 | |
| 271 | - const off = readOffsetFromElementConfig(el) | |
| 272 | - if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, base) | |
| 652 | + if (vst === 'AUTO_DB' && (type === 'DATE' || type === 'TIME' || type === 'DURATION' || isOffsetField)) { | |
| 653 | + const off = readOffsetFromElementConfig({ ...el, config: cfg }) | |
| 654 | + if (off) return resolveFromOffsetAmount({ ...el, config: cfg }, off.amount, off.unit, base) | |
| 655 | + return resolveFromOffsetAmount({ ...el, config: cfg }, 0, 'Days', base) | |
| 656 | + } | |
| 657 | + | |
| 658 | + const off = readOffsetFromElementConfig({ ...el, config: cfg }) | |
| 659 | + if (off) return resolveFromOffsetAmount({ ...el, config: cfg }, off.amount, off.unit, base) | |
| 660 | + if (isOffsetField) { | |
| 661 | + return resolveFromOffsetAmount({ ...el, config: cfg }, 0, 'Days', base) | |
| 662 | + } | |
| 273 | 663 | if ( |
| 274 | 664 | rawText && |
| 275 | - !tryParsePrintInputOffsetStored(rawText) && | |
| 276 | - isLikelyResolvedDateTimeLiteral(rawText) && | |
| 277 | - isUniAppDateTimeOffsetField(el) | |
| 665 | + !parseStoredPrintInputOffset(rawText) && | |
| 666 | + isLikelyResolvedDateTimeLiteral(rawText) | |
| 278 | 667 | ) { |
| 279 | 668 | return applyElementPrefix(cfg, rawText) |
| 280 | 669 | } |
| 281 | - return resolveFromOffsetAmount(el, 0, 'Days', base) | |
| 670 | + return resolveFromOffsetAmount({ ...el, config: cfg }, 0, 'Days', base) | |
| 671 | +} | |
| 672 | + | |
| 673 | +/** 底部黑条星期(durationdate2);Use By 的 durationdate 不在此列 */ | |
| 674 | +export function resolveWeekdayDisplayForElement ( | |
| 675 | + el: SystemTemplateElementBase, | |
| 676 | + base: Date = new Date(), | |
| 677 | +): string { | |
| 678 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 679 | + sanitizeDateElementConfig(el, cfg) | |
| 680 | + const frozen = readFrozenWeekdayDisplayFromConfig(cfg) | |
| 681 | + if (frozen) return applyElementPrefix(cfg, frozen) | |
| 682 | + const off = readOffsetFromElementConfig({ ...el, config: cfg }) | |
| 683 | + const amount = off?.amount ?? 0 | |
| 684 | + const unit = off?.unit ?? 'Days' | |
| 685 | + const d = applyOffsetToDate(base, amount, unit) | |
| 686 | + const preserved = String(cfg.__dateDisplayFormat ?? cfg.__DateDisplayFormat ?? '').trim() | |
| 687 | + const pattern = | |
| 688 | + preserved === 'DAY (WED)' ? 'DAY (WED)' : 'FULLY DAY(WEDNESDAY)' | |
| 689 | + return applyElementPrefix(cfg, formatDateByPreset(pattern, d)) | |
| 282 | 690 | } |
| 283 | 691 | |
| 284 | 692 | export function tryParsePrintInputOffsetStored ( |
| 285 | 693 | raw: string | null | undefined, |
| 286 | 694 | ): { unit: string; value: string } | null { |
| 287 | 695 | const t = String(raw ?? '').trim() |
| 288 | - if (!t.startsWith('{')) return null | |
| 289 | - try { | |
| 290 | - const o = JSON.parse(t) as unknown | |
| 291 | - if (o == null || typeof o !== 'object' || Array.isArray(o)) return null | |
| 292 | - const rec = o as Record<string, unknown> | |
| 293 | - const unit = String(rec.unit ?? rec.Unit ?? '').trim() | |
| 294 | - const value = String(rec.value ?? rec.Value ?? '') | |
| 295 | - if (!unit || !OFFSET_UNITS.has(unit)) return null | |
| 296 | - return { unit, value } | |
| 297 | - } catch { | |
| 298 | - return null | |
| 696 | + if (!t) return null | |
| 697 | + if (t.startsWith('{')) { | |
| 698 | + try { | |
| 699 | + const o = JSON.parse(t) as unknown | |
| 700 | + if (o == null || typeof o !== 'object' || Array.isArray(o)) return null | |
| 701 | + const rec = o as Record<string, unknown> | |
| 702 | + const unit = String(rec.unit ?? rec.Unit ?? '').trim() | |
| 703 | + const value = String(rec.value ?? rec.Value ?? '') | |
| 704 | + if (!unit || !OFFSET_UNITS.has(unit)) return null | |
| 705 | + return { unit, value } | |
| 706 | + } catch { | |
| 707 | + return null | |
| 708 | + } | |
| 299 | 709 | } |
| 710 | + /** 兼容旧版 templateProductDefaults 仅存纯数字(表示 Days) */ | |
| 711 | + if (/^\d+$/.test(t)) return { unit: 'Days', value: t } | |
| 712 | + return null | |
| 300 | 713 | } |
| 301 | 714 | |
| 302 | -/** 与 Web isDateTimeDataEntryField 对齐(App 侧元素无 typeAdd 时用命名兜底) */ | |
| 715 | +/** 与 Web offsetFieldUiStateFromStored 对齐 */ | |
| 716 | +export function parseStoredPrintInputOffset ( | |
| 717 | + raw: string | null | undefined, | |
| 718 | +): { unit: string; value: string } | null { | |
| 719 | + return tryParsePrintInputOffsetStored(raw) | |
| 720 | +} | |
| 721 | + | |
| 722 | +/** 与 Web isDateTimeLiveResolveField 对齐:含 auto current date/time 等相对基准解析字段 */ | |
| 303 | 723 | export function isUniAppDateTimeOffsetField (el: SystemTemplateElementBase): boolean { |
| 724 | + if (isPrintInputLiteralField(el)) return false | |
| 304 | 725 | const type = String(el.type || '').toUpperCase() |
| 305 | 726 | const cfg = (el.config || {}) as Record<string, unknown> |
| 306 | 727 | if (type === 'TIME' || type === 'DURATION') return true |
| 307 | 728 | if (type !== 'DATE') return false |
| 308 | 729 | const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() |
| 309 | 730 | if (it === 'datetime' || it === 'date') return true |
| 310 | - const ta = String((el as { typeAdd?: string }).typeAdd ?? '').trim().toLowerCase().replace(/\s+/g, ' ') | |
| 731 | + const ta = resolveElementTypeAdd(el).toLowerCase().replace(/\s+/g, ' ') | |
| 311 | 732 | if (ta === 'label_duration date' || ta.includes('duration date')) return true |
| 312 | 733 | if (ta === 'auto_current date' || ta.includes('current date')) return true |
| 313 | 734 | if (ta === 'auto_current time' || ta.includes('current time')) return true |
| ... | ... | @@ -317,6 +738,10 @@ export function isUniAppDateTimeOffsetField (el: SystemTemplateElementBase): boo |
| 317 | 738 | } |
| 318 | 739 | |
| 319 | 740 | /** |
| 741 | + * 将 templateProductDefaults 里单格字符串转为画布用的展示文案(相对 now)。 | |
| 742 | + * 非 JSON 或无法解析时返回原串(兼容旧版已展开的日期文案)。 | |
| 743 | + */ | |
| 744 | +/** | |
| 320 | 745 | * 预览/出纸前:按同一基准时刻重算所有 DATE/TIME/DURATION 及偏移类字段,保证 Prep 与 Use By 一致。 |
| 321 | 746 | */ |
| 322 | 747 | export function applyLiveDateTimeFieldsToTemplate ( |
| ... | ... | @@ -325,17 +750,53 @@ export function applyLiveDateTimeFieldsToTemplate ( |
| 325 | 750 | ): SystemLabelTemplate { |
| 326 | 751 | const elements = (template.elements || []).map((el) => { |
| 327 | 752 | const type = String(el.type || '').toUpperCase() |
| 753 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 754 | + const weekdayEl = isWeekdayBarElement(el) | |
| 755 | + if (isPrintInputLiteralField(el)) return el | |
| 328 | 756 | const needsLive = |
| 329 | 757 | type === 'DATE' || |
| 330 | 758 | type === 'TIME' || |
| 331 | 759 | type === 'DURATION' || |
| 332 | - isUniAppDateTimeOffsetField(el) | |
| 760 | + isUniAppDateTimeOffsetField(el) || | |
| 761 | + weekdayEl | |
| 333 | 762 | if (!needsLive) return el |
| 334 | - const live = resolveElementDateTimeDisplay(el, base) | |
| 763 | + sanitizeDateElementConfig(el, cfg) | |
| 764 | + | |
| 765 | + /** 仅 durationdate2 黑条:偏移 + FULLY DAY */ | |
| 766 | + if (weekdayEl) { | |
| 767 | + const live = resolveWeekdayDisplayForElement({ ...el, config: cfg }, base) | |
| 768 | + if (!live || !String(live).trim() || isCorruptedDateDisplayFormat(live)) return el | |
| 769 | + const raw = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 770 | + const keepsOffsetStorage = | |
| 771 | + isUniAppDateTimeOffsetField(el) && | |
| 772 | + (parseStoredPrintInputOffset(raw) != null || /^\d+$/.test(raw)) | |
| 773 | + cfg.__previewFormatted = live | |
| 774 | + if (!keepsOffsetStorage) { | |
| 775 | + cfg.text = live | |
| 776 | + cfg.Text = live | |
| 777 | + } | |
| 778 | + return { ...el, config: cfg } | |
| 779 | + } | |
| 780 | + | |
| 781 | + let live: string | null = null | |
| 782 | + try { | |
| 783 | + live = resolveElementDateTimeDisplay({ ...el, config: cfg }, base) | |
| 784 | + } catch (err) { | |
| 785 | + console.warn('[printInputOffset] applyLiveDateTimeFields resolve failed', el.id, err) | |
| 786 | + } | |
| 787 | + if (live != null && isCorruptedDateDisplayFormat(live)) { | |
| 788 | + live = null | |
| 789 | + } | |
| 335 | 790 | if (live == null || !String(live).trim()) return el |
| 336 | - const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 337 | - cfg.text = live | |
| 338 | - cfg.Text = live | |
| 791 | + const raw = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 792 | + const keepsOffsetStorage = | |
| 793 | + isUniAppDateTimeOffsetField(el) && | |
| 794 | + (parseStoredPrintInputOffset(raw) != null || /^\d+$/.test(raw)) | |
| 795 | + cfg.__previewFormatted = live | |
| 796 | + if (!keepsOffsetStorage) { | |
| 797 | + cfg.text = live | |
| 798 | + cfg.Text = live | |
| 799 | + } | |
| 339 | 800 | return { ...el, config: cfg } |
| 340 | 801 | }) |
| 341 | 802 | return { ...template, elements } |
| ... | ... | @@ -349,7 +810,7 @@ export function resolveTemplateDefaultValueForElement ( |
| 349 | 810 | const s = String(stored ?? '').trim() |
| 350 | 811 | if (!s) return '' |
| 351 | 812 | if (!isUniAppDateTimeOffsetField(el)) return s |
| 352 | - const parsed = tryParsePrintInputOffsetStored(s) | |
| 813 | + const parsed = parseStoredPrintInputOffset(s) | |
| 353 | 814 | if (parsed) { |
| 354 | 815 | const amount = normalizeOffsetAmount(parsed.value) |
| 355 | 816 | if (amount === null) return '' |
| ... | ... | @@ -357,7 +818,7 @@ export function resolveTemplateDefaultValueForElement ( |
| 357 | 818 | } |
| 358 | 819 | if (isLikelyResolvedDateTimeLiteral(s)) { |
| 359 | 820 | const off = readOffsetFromElementConfig(el) |
| 360 | - return resolveFromOffsetAmount(el, off?.amount ?? 0, off?.unit ?? 'Days', now) | |
| 821 | + if (off) return resolveFromOffsetAmount(el, off.amount, off.unit, now) | |
| 361 | 822 | } |
| 362 | 823 | return s |
| 363 | 824 | } | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/printInputOptions.ts
| ... | ... | @@ -3,6 +3,28 @@ import type { |
| 3 | 3 | SystemLabelTemplate, |
| 4 | 4 | SystemTemplateElementBase, |
| 5 | 5 | } from '../print/types/printer' |
| 6 | +import { isUniAppDateTimeOffsetField, isPrintInputLiteralField } from './printInputOffset' | |
| 7 | +import { formatWeightDisplay } from '../weightElement' | |
| 8 | + | |
| 9 | +/** Entered When Printing:config.text 为 Pre-text,仅作 App 打印页提示,不出现在标签上 */ | |
| 10 | +export function printInputPreText(el: SystemTemplateElementBase): string { | |
| 11 | + const c = el.config || {} | |
| 12 | + return String(c.text ?? c.Text ?? '').trim() | |
| 13 | +} | |
| 14 | + | |
| 15 | +export function printInputFieldLabelFromElement(el: SystemTemplateElementBase): string { | |
| 16 | + const preText = printInputPreText(el) | |
| 17 | + if (preText) return preText | |
| 18 | + const type = String(el.type || '').toUpperCase() | |
| 19 | + const c = el.config || {} | |
| 20 | + const it = String(c.inputType ?? c.InputType ?? '').toLowerCase() | |
| 21 | + if (it === 'number') return 'Number' | |
| 22 | + if (it === 'text') return 'Text' | |
| 23 | + if (it === 'options') return 'Multiple Options' | |
| 24 | + if (it === 'datetime' || it === 'date') return 'Date & Time' | |
| 25 | + if (type === 'WEIGHT') return 'Weight' | |
| 26 | + return type.replace(/_/g, ' ') | |
| 27 | +} | |
| 6 | 28 | |
| 7 | 29 | /** |
| 8 | 30 | * 打印/预览 printInputJson 的 key:与接口文档一致优先 inputKey,否则 elementName,最后兜底元素 id。 |
| ... | ... | @@ -62,18 +84,32 @@ export function readFreeFieldValuesFromTemplate(template: SystemLabelTemplate): |
| 62 | 84 | const out: Record<string, string> = {} |
| 63 | 85 | for (const el of template.elements || []) { |
| 64 | 86 | if (!isPrintInputFreeFieldElement(el)) continue |
| 87 | + if (isUniAppDateTimeOffsetField(el)) continue | |
| 88 | + if (isPrintInputLiteralField(el)) { | |
| 89 | + const c = el.config || {} | |
| 90 | + const it = String(c.inputType ?? c.InputType ?? '').toLowerCase() | |
| 91 | + const type = String(el.type || '').toUpperCase() | |
| 92 | + if (type === 'WEIGHT') { | |
| 93 | + const v = c.value ?? c.Value | |
| 94 | + out[el.id] = v != null && String(v).trim() !== '' ? String(v).trim() : '' | |
| 95 | + continue | |
| 96 | + } | |
| 97 | + if (it === 'number') { | |
| 98 | + out[el.id] = '0' | |
| 99 | + continue | |
| 100 | + } | |
| 101 | + out[el.id] = '' | |
| 102 | + continue | |
| 103 | + } | |
| 65 | 104 | const c = el.config || {} |
| 66 | 105 | const type = String(el.type || '').toUpperCase() |
| 67 | - let initial = '' | |
| 68 | 106 | if (type === 'WEIGHT') { |
| 69 | 107 | const v = c.value ?? c.Value |
| 70 | - if (v != null && String(v).trim() !== '') initial = String(v).trim() | |
| 71 | - } else { | |
| 72 | - const fmt = String(c.format ?? c.Format ?? '').trim() | |
| 73 | - const t = String(c.text ?? c.Text ?? '').trim() | |
| 74 | - if (t && (!fmt || t !== fmt)) initial = t | |
| 108 | + out[el.id] = v != null && String(v).trim() !== '' ? String(v).trim() : '' | |
| 109 | + continue | |
| 75 | 110 | } |
| 76 | - out[el.id] = initial | |
| 111 | + // DATE/TIME 等:config.text 为 Pre-text,初始录入为空 | |
| 112 | + out[el.id] = '' | |
| 77 | 113 | } |
| 78 | 114 | return out |
| 79 | 115 | } |
| ... | ... | @@ -101,12 +137,23 @@ export function mergePrintInputFreeFields( |
| 101 | 137 | ...template, |
| 102 | 138 | elements: (template.elements || []).map((el) => { |
| 103 | 139 | if (!isPrintInputFreeFieldElement(el)) return el |
| 140 | + if (isUniAppDateTimeOffsetField(el)) return el | |
| 104 | 141 | const cfg = { ...el.config } |
| 105 | 142 | const raw = String(values[el.id] ?? '').trim() |
| 106 | 143 | const unit = String(cfg.unit ?? cfg.Unit ?? '').trim() |
| 107 | 144 | const type = String(el.type || '').toUpperCase() |
| 108 | 145 | |
| 109 | 146 | if (!raw) { |
| 147 | + if (isPrintInputLiteralField(el)) { | |
| 148 | + const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() | |
| 149 | + if (type === 'WEIGHT') { | |
| 150 | + return { ...el, config: { ...cfg, text: '', value: '' } } | |
| 151 | + } | |
| 152 | + if (it === 'number') { | |
| 153 | + return { ...el, config: { ...cfg, text: '' } } | |
| 154 | + } | |
| 155 | + return { ...el, config: { ...cfg, text: '' } } | |
| 156 | + } | |
| 110 | 157 | if (type === 'DATE' || type === 'TIME') { |
| 111 | 158 | const fmt = String(cfg.format ?? cfg.Format ?? '') |
| 112 | 159 | return { ...el, config: { ...cfg, text: fmt } } |
| ... | ... | @@ -120,7 +167,12 @@ export function mergePrintInputFreeFields( |
| 120 | 167 | return { ...el, config: { ...cfg, text: ph } } |
| 121 | 168 | } |
| 122 | 169 | |
| 123 | - const display = unit && !raw.endsWith(unit) ? `${raw}${unit}` : raw | |
| 170 | + const display = | |
| 171 | + type === 'WEIGHT' | |
| 172 | + ? formatWeightDisplay(raw, unit) | |
| 173 | + : unit && !raw.endsWith(unit) | |
| 174 | + ? `${raw}${unit}` | |
| 175 | + : raw | |
| 124 | 176 | const next = { ...cfg, text: display } as Record<string, any> |
| 125 | 177 | if (type === 'WEIGHT') { |
| 126 | 178 | next.value = raw | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
| 1 | 1 | import type { RawImageDataSource, SystemLabelTemplate, SystemTemplateElementBase } from '../print/types/printer' |
| 2 | 2 | import { resolveMediaUrlForApp, storedValueLooksLikeImagePath } from '../resolveMediaUrl' |
| 3 | -import { sortElementsForPreview } from './normalizePreviewTemplate' | |
| 4 | -import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral } from './printInputOffset' | |
| 3 | +import { sortElementsForPreview, normalizeTemplatePrintOrientation } from './normalizePreviewTemplate' | |
| 4 | +import { resolveLabelDesignCanvasPx } from '../print/templatePhysicalMm' | |
| 5 | +import { | |
| 6 | + resolveElementDateTimeDisplay, | |
| 7 | + isLikelyResolvedDateTimeLiteral, | |
| 8 | + isCorruptedDateDisplayFormat, | |
| 9 | + isStoredPrintInputOffsetPayload, | |
| 10 | + sanitizeDateElementConfig, | |
| 11 | + resolveWeekdayDisplayForElement, | |
| 12 | + isUniAppDateTimeOffsetField, | |
| 13 | + isWeekdayBarElement, | |
| 14 | + isPrintInputLiteralField, | |
| 15 | + resolvePrintInputLiteralDisplay, | |
| 16 | +} from './printInputOffset' | |
| 17 | +import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement' | |
| 5 | 18 | import QRCode from 'qrcode' |
| 19 | +import { readInvertColors } from '../invertColorsConfig' | |
| 20 | +import { | |
| 21 | + ensureLabelEditorFontsForTemplate, | |
| 22 | + isLabelEditorFontItalicLoaded, | |
| 23 | + resolveLabelEditorFontFamily, | |
| 24 | +} from '../labelEditorFonts' | |
| 25 | +import { | |
| 26 | + computeVerticalTextBlockOffset, | |
| 27 | + readVerticalAlign, | |
| 28 | + readElementBorder, | |
| 29 | + readFontStyle, | |
| 30 | + readFontWeight, | |
| 31 | + readTextDecoration, | |
| 32 | + readElementRotation, | |
| 33 | + elementRotationDegrees, | |
| 34 | + isShortSingleLineTextBox, | |
| 35 | + type TextVerticalAlign, | |
| 36 | +} from '../textElementLayout' | |
| 6 | 37 | |
| 7 | -const NUTRITION_FIXED_ITEMS = [ | |
| 8 | - { key: 'fat', label: 'Total Fat' }, | |
| 9 | - { key: 'saturatedFat', label: 'Saturated Fat' }, | |
| 10 | - { key: 'transFat', label: 'Trans Fat' }, | |
| 11 | - { key: 'cholesterol', label: 'Cholesterol' }, | |
| 12 | - { key: 'sodium', label: 'Sodium' }, | |
| 13 | - { key: 'carbs', label: 'Total Carbohydrates' }, | |
| 14 | - { key: 'dietaryFiber', label: 'Dietary Fiber' }, | |
| 15 | - { key: 'totalSugar', label: 'Total Sugar' }, | |
| 16 | - { key: 'protein', label: 'Protein' }, | |
| 17 | - { key: 'vitaminA', label: 'Vitamin A' }, | |
| 18 | - { key: 'vitaminC', label: 'Vitamin C' }, | |
| 19 | - { key: 'calcium', label: 'Calcium' }, | |
| 20 | - { key: 'iron', label: 'Iron' }, | |
| 21 | -] | |
| 38 | +import { computeImageDrawRect, readImageScaleMode } from '../imageScaleMode' | |
| 39 | +import { | |
| 40 | + buildNutritionFactsViewModel, | |
| 41 | + DEFAULT_NUTRITION_FOOTER_NOTE, | |
| 42 | + NUTRITION_AMOUNT_COL_WIDTH, | |
| 43 | + NUTRITION_BODY_FONT_SIZE, | |
| 44 | + NUTRITION_PCT_COL_WIDTH, | |
| 45 | + type NutritionDivider, | |
| 46 | +} from '../nutritionFactsLayout' | |
| 22 | 47 | |
| 23 | 48 | /** 与 Web LabelCanvas.unitToPx 一致:cm 用 37.8px/inch,保证与后台模板坐标系一致 */ |
| 24 | 49 | const PX_PER_CM = 37.8 |
| ... | ... | @@ -62,6 +87,399 @@ function readFillColor(config: Record<string, any>): string { |
| 62 | 87 | return String(config.color ?? config.Color ?? '#111827') |
| 63 | 88 | } |
| 64 | 89 | |
| 90 | +/** 底部黑条:模板约定 verticalAlign=center,在元素框内居中铺黑底(与 Web flex 一致) */ | |
| 91 | +function resolveDrawVerticalAlign( | |
| 92 | + config: Record<string, any>, | |
| 93 | + el: SystemTemplateElementBase, | |
| 94 | + opts: { invertedBar: boolean; weekdayBar: boolean }, | |
| 95 | +): TextVerticalAlign { | |
| 96 | + const merged = { | |
| 97 | + ...config, | |
| 98 | + verticalAlign: | |
| 99 | + config.verticalAlign | |
| 100 | + ?? config.VerticalAlign | |
| 101 | + ?? (el as Record<string, unknown>).verticalAlign | |
| 102 | + ?? (el as Record<string, unknown>).VerticalAlign, | |
| 103 | + } | |
| 104 | + const align = readVerticalAlign(merged) | |
| 105 | + if (opts.invertedBar && opts.weekdayBar) { | |
| 106 | + if (align === 'top') return 'center' | |
| 107 | + return align | |
| 108 | + } | |
| 109 | + return align | |
| 110 | +} | |
| 111 | + | |
| 112 | +function lineHeightForTextElement( | |
| 113 | + printFontSize: number, | |
| 114 | + isDateTimeType: boolean, | |
| 115 | +): number { | |
| 116 | + return isDateTimeType | |
| 117 | + ? Math.max(printFontSize + 2, Math.round(printFontSize * 1.2)) | |
| 118 | + : Math.max(printFontSize + 2, Math.round(printFontSize * 1.25)) | |
| 119 | +} | |
| 120 | + | |
| 121 | +/** | |
| 122 | + * Web AlignedTextBox + 黑底行:行盒高度略小于 line-height 1.2,在元素框内垂直居中。 | |
| 123 | + * layout 用较薄行盒算偏移,绘制用略高条带承托粗体。 | |
| 124 | + */ | |
| 125 | +function layoutInvertedWeekdayStrip( | |
| 126 | + by: number, | |
| 127 | + bh: number, | |
| 128 | + printFontSize: number, | |
| 129 | + verticalAlign: TextVerticalAlign, | |
| 130 | +): { stripTop: number; stripHeight: number; textY: number } { | |
| 131 | + const layoutH = Math.max(printFontSize + 2, Math.round(printFontSize * 1.12)) | |
| 132 | + const stripHeight = Math.max(layoutH, Math.round(printFontSize * 1.22)) | |
| 133 | + const effectiveAlign = verticalAlign === 'top' ? 'center' : verticalAlign | |
| 134 | + const verticalOffset = computeVerticalTextBlockOffset(bh, layoutH, effectiveAlign) | |
| 135 | + const stripTop = by + verticalOffset | |
| 136 | + const textY = stripTop + Math.round(stripHeight * 0.74) | |
| 137 | + return { stripTop, stripHeight, textY } | |
| 138 | +} | |
| 139 | + | |
| 140 | +/** 单行在元素框内垂直对齐(与 Web flex + verticalAlign 一致);多行仍用 alphabetic baseline */ | |
| 141 | +function computeLineDrawY( | |
| 142 | + by: number, | |
| 143 | + bh: number, | |
| 144 | + li: number, | |
| 145 | + fittedLineHeight: number, | |
| 146 | + visibleLineCount: number, | |
| 147 | + printFontSize: number, | |
| 148 | + verticalAlign: TextVerticalAlign, | |
| 149 | +): { y: number; baseline: 'middle' | 'alphabetic' } { | |
| 150 | + const blockHeight = visibleLineCount * fittedLineHeight | |
| 151 | + const verticalOffset = computeVerticalTextBlockOffset(bh, blockHeight, verticalAlign) | |
| 152 | + const lineTop = by + verticalOffset + li * fittedLineHeight | |
| 153 | + if (visibleLineCount === 1 && verticalAlign === 'center') { | |
| 154 | + return { y: by + bh / 2, baseline: 'middle' } | |
| 155 | + } | |
| 156 | + if (visibleLineCount === 1 && verticalAlign === 'bottom') { | |
| 157 | + return { | |
| 158 | + y: by + bh - Math.max(1, Math.round(printFontSize * 0.12)), | |
| 159 | + baseline: 'alphabetic', | |
| 160 | + } | |
| 161 | + } | |
| 162 | + if (visibleLineCount > 1) { | |
| 163 | + return { y: lineTop + fittedLineHeight / 2, baseline: 'middle' } | |
| 164 | + } | |
| 165 | + return { y: lineTop + Math.round(printFontSize * 0.82), baseline: 'alphabetic' } | |
| 166 | +} | |
| 167 | + | |
| 168 | +/** middle 基线时 y 在字高中心,下划线贴在数字底部略下方 */ | |
| 169 | +function computeUnderlineDrawY( | |
| 170 | + y: number, | |
| 171 | + fontSize: number, | |
| 172 | + baseline: 'middle' | 'alphabetic', | |
| 173 | +): number { | |
| 174 | + if (baseline === 'middle') { | |
| 175 | + /** 数字 cap-height 约 0.72em,自 center 到字底约 0.36em */ | |
| 176 | + return y + Math.round(fontSize * 0.36) + Math.max(1, Math.round(fontSize * 0.04)) | |
| 177 | + } | |
| 178 | + return y + Math.max(1, Math.round(fontSize * 0.1)) | |
| 179 | +} | |
| 180 | + | |
| 181 | +function readCanvasFontWeight(config: Record<string, any>): 'normal' | 'bold' { | |
| 182 | + if (readFontWeight(config) === 'bold') return 'bold' | |
| 183 | + const family = resolveLabelEditorFontFamily(config).toLowerCase() | |
| 184 | + if (family.includes('bold')) return 'bold' | |
| 185 | + return 'normal' | |
| 186 | +} | |
| 187 | + | |
| 188 | +function applyCanvasFontFromConfig( | |
| 189 | + ctx: UniApp.CanvasContext, | |
| 190 | + config: Record<string, any>, | |
| 191 | + fontSize: number, | |
| 192 | +): void { | |
| 193 | + const fontFamily = resolveLabelEditorFontFamily(config) | |
| 194 | + const anyCtx = ctx as any | |
| 195 | + const weight = readCanvasFontWeight(config) | |
| 196 | + const style = readFontStyle(config) === 'italic' ? 'italic' : 'normal' | |
| 197 | + if (typeof anyCtx.setFontFamily === 'function') { | |
| 198 | + anyCtx.setFontFamily(fontFamily) | |
| 199 | + } | |
| 200 | + if (typeof anyCtx.setFontWeight === 'function') { | |
| 201 | + anyCtx.setFontWeight(weight) | |
| 202 | + } | |
| 203 | + if (typeof anyCtx.font !== 'undefined') { | |
| 204 | + anyCtx.font = `${style} ${weight} ${fontSize}px "${fontFamily}"` | |
| 205 | + } | |
| 206 | +} | |
| 207 | + | |
| 208 | +function approxTextWidth(text: string, fontSize: number): number { | |
| 209 | + return Math.max(4, String(text).length * fontSize * 0.55) | |
| 210 | +} | |
| 211 | + | |
| 212 | +function canvasSupportsMeasureText(ctx: UniApp.CanvasContext): boolean { | |
| 213 | + return typeof (ctx as any).measureText === 'function' | |
| 214 | +} | |
| 215 | + | |
| 216 | +function measureCanvasLineWidth( | |
| 217 | + ctx: UniApp.CanvasContext, | |
| 218 | + text: string, | |
| 219 | + fontSize: number, | |
| 220 | + config: Record<string, any>, | |
| 221 | +): number { | |
| 222 | + if (canvasSupportsMeasureText(ctx)) { | |
| 223 | + try { | |
| 224 | + applyCanvasFontFromConfig(ctx, config, fontSize) | |
| 225 | + const w = Number((ctx as any).measureText(String(text)).width) | |
| 226 | + if (Number.isFinite(w) && w > 0) return w | |
| 227 | + } catch { | |
| 228 | + /* fall through */ | |
| 229 | + } | |
| 230 | + } | |
| 231 | + return approxTextWidth(text, fontSize) | |
| 232 | +} | |
| 233 | + | |
| 234 | +function pushLongWordByMeasuredWidth( | |
| 235 | + ctx: UniApp.CanvasContext, | |
| 236 | + word: string, | |
| 237 | + maxWidth: number, | |
| 238 | + fontSize: number, | |
| 239 | + config: Record<string, any>, | |
| 240 | + out: string[], | |
| 241 | +): string { | |
| 242 | + let buf = '' | |
| 243 | + for (let i = 0; i < word.length; i++) { | |
| 244 | + const ch = word.charAt(i) | |
| 245 | + const trial = buf + ch | |
| 246 | + if (buf && measureCanvasLineWidth(ctx, trial, fontSize, config) > maxWidth) { | |
| 247 | + out.push(buf) | |
| 248 | + buf = ch | |
| 249 | + } else { | |
| 250 | + buf = trial | |
| 251 | + } | |
| 252 | + } | |
| 253 | + return buf | |
| 254 | +} | |
| 255 | + | |
| 256 | +function flushMeasuredRemainder( | |
| 257 | + ctx: UniApp.CanvasContext, | |
| 258 | + remainder: string, | |
| 259 | + maxWidth: number, | |
| 260 | + fontSize: number, | |
| 261 | + config: Record<string, any>, | |
| 262 | + out: string[], | |
| 263 | +): void { | |
| 264 | + let rest = String(remainder ?? '') | |
| 265 | + let guard = 0 | |
| 266 | + const guardMax = Math.max(64, rest.length * 4) | |
| 267 | + while (rest.length > 0 && guard++ < guardMax) { | |
| 268 | + const before = out.length | |
| 269 | + rest = pushLongWordByMeasuredWidth(ctx, rest, maxWidth, fontSize, config, out) | |
| 270 | + if (!rest) break | |
| 271 | + if (out.length === before) { | |
| 272 | + /** 单字宽于行宽或 push 未产出新行:强制推进 1 字符,避免 while(rest) 死循环 */ | |
| 273 | + out.push(rest.slice(0, 1)) | |
| 274 | + rest = rest.slice(1) | |
| 275 | + continue | |
| 276 | + } | |
| 277 | + if (measureCanvasLineWidth(ctx, rest, fontSize, config) <= maxWidth) { | |
| 278 | + out.push(rest) | |
| 279 | + break | |
| 280 | + } | |
| 281 | + } | |
| 282 | + if (rest.length > 0 && guard >= guardMax) { | |
| 283 | + out.push(rest) | |
| 284 | + } | |
| 285 | +} | |
| 286 | + | |
| 287 | +/** 优先 measureText 按真实宽度断行;breakAll 与 Web `break-all` 一致 */ | |
| 288 | +function wrapTextToCanvasWidth( | |
| 289 | + ctx: UniApp.CanvasContext, | |
| 290 | + text: string, | |
| 291 | + innerWidthPx: number, | |
| 292 | + fontSize: number, | |
| 293 | + config: Record<string, any>, | |
| 294 | + breakAll = false, | |
| 295 | +): string[] { | |
| 296 | + const maxW = Math.max(4, innerWidthPx) | |
| 297 | + const rawLines = String(text ?? '').split(/\r?\n/) | |
| 298 | + const out: string[] = [] | |
| 299 | + | |
| 300 | + for (const segment of rawLines) { | |
| 301 | + const s = String(segment ?? '') | |
| 302 | + if (!s.trim()) { | |
| 303 | + out.push(s) | |
| 304 | + continue | |
| 305 | + } | |
| 306 | + if (measureCanvasLineWidth(ctx, s, fontSize, config) <= maxW) { | |
| 307 | + out.push(s) | |
| 308 | + continue | |
| 309 | + } | |
| 310 | + if (!canvasSupportsMeasureText(ctx)) { | |
| 311 | + out.push(...wrapTextToWidth(s, maxCharsPerLine(maxW, fontSize))) | |
| 312 | + continue | |
| 313 | + } | |
| 314 | + if (breakAll) { | |
| 315 | + flushMeasuredRemainder(ctx, s, maxW, fontSize, config, out) | |
| 316 | + continue | |
| 317 | + } | |
| 318 | + const words = s.trim().split(/\s+/).filter(Boolean) | |
| 319 | + let cur = '' | |
| 320 | + for (const word of words) { | |
| 321 | + const trial = cur ? `${cur} ${word}` : word | |
| 322 | + if (measureCanvasLineWidth(ctx, trial, fontSize, config) <= maxW) { | |
| 323 | + cur = trial | |
| 324 | + } else { | |
| 325 | + if (cur) out.push(cur) | |
| 326 | + if (measureCanvasLineWidth(ctx, word, fontSize, config) <= maxW) { | |
| 327 | + cur = word | |
| 328 | + } else { | |
| 329 | + cur = pushLongWordByMeasuredWidth(ctx, word, maxW, fontSize, config, out) | |
| 330 | + } | |
| 331 | + } | |
| 332 | + } | |
| 333 | + if (cur) out.push(cur) | |
| 334 | + } | |
| 335 | + return out.length ? out : [''] | |
| 336 | +} | |
| 337 | + | |
| 338 | +/** 黑底/静态文案行高:与 Web leading-tight(≈1.25)一致,避免矮框多行末行被 clip */ | |
| 339 | +function invertedStaticLineHeight(fontSize: number): number { | |
| 340 | + return lineHeightForTextElement(fontSize, false) | |
| 341 | +} | |
| 342 | + | |
| 343 | +function shouldBreakAllTextWrap(_type: string, invertedStatic: boolean): boolean { | |
| 344 | + /** 仅黑底窄框标签需 break-all;长地址等普通 TEXT_STATIC 用词边界换行,避免逐字 measureText 卡死 */ | |
| 345 | + return invertedStatic | |
| 346 | +} | |
| 347 | + | |
| 348 | +function maxLinesForBox( | |
| 349 | + layoutBoxH: number, | |
| 350 | + fittedLineHeight: number, | |
| 351 | + printFontSize: number, | |
| 352 | +): number { | |
| 353 | + if (layoutBoxH < printFontSize) return 1 | |
| 354 | + const descentPad = Math.max(2, Math.round(printFontSize * 0.14)) | |
| 355 | + return Math.max(1, Math.floor((layoutBoxH + descentPad) / fittedLineHeight)) | |
| 356 | +} | |
| 357 | + | |
| 358 | +function fitFontSizeToInnerWidth( | |
| 359 | + ctx: UniApp.CanvasContext, | |
| 360 | + line: string, | |
| 361 | + innerW: number, | |
| 362 | + fontSize: number, | |
| 363 | + config: Record<string, any>, | |
| 364 | +): number { | |
| 365 | + if (!line || innerW <= 0) return fontSize | |
| 366 | + if (!canvasSupportsMeasureText(ctx)) { | |
| 367 | + const w = approxTextWidth(line, fontSize) | |
| 368 | + if (w <= innerW) return fontSize | |
| 369 | + return Math.max(8, Math.floor(fontSize * (innerW / w) * 0.98)) | |
| 370 | + } | |
| 371 | + let size = fontSize | |
| 372 | + for (let i = 0; i < 5; i++) { | |
| 373 | + const w = measureCanvasLineWidth(ctx, line, size, config) | |
| 374 | + if (w <= innerW || w <= 0) return size | |
| 375 | + size = Math.max(8, Math.floor(size * (innerW / w) * 0.98)) | |
| 376 | + } | |
| 377 | + return Math.max(8, size) | |
| 378 | +} | |
| 379 | + | |
| 380 | +function resolveTextLinesForDraw( | |
| 381 | + ctx: UniApp.CanvasContext, | |
| 382 | + text: string, | |
| 383 | + innerW: number, | |
| 384 | + layoutBoxH: number, | |
| 385 | + printFontSize: number, | |
| 386 | + fittedLineHeight: number, | |
| 387 | + config: Record<string, any>, | |
| 388 | + opts: { breakAll: boolean; isDateTimeType: boolean }, | |
| 389 | +): { lines: string[]; drawFontSize: number } { | |
| 390 | + const trimmed = String(text ?? '').trim() | |
| 391 | + if (!trimmed) return { lines: [''], drawFontSize: printFontSize } | |
| 392 | + if (opts.isDateTimeType) { | |
| 393 | + return { lines: [trimmed.replace(/\s+/g, ' ')], drawFontSize: printFontSize } | |
| 394 | + } | |
| 395 | + const preferSingleLine = | |
| 396 | + !opts.breakAll && | |
| 397 | + !trimmed.includes('\n') && | |
| 398 | + isShortSingleLineTextBox(layoutBoxH, printFontSize, trimmed, { breakAll: opts.breakAll }) | |
| 399 | + if (preferSingleLine) { | |
| 400 | + const drawFontSize = fitFontSizeToInnerWidth(ctx, trimmed, innerW, printFontSize, config) | |
| 401 | + return { lines: [trimmed], drawFontSize } | |
| 402 | + } | |
| 403 | + return { | |
| 404 | + lines: wrapTextToCanvasWidth(ctx, trimmed, innerW, printFontSize, config, opts.breakAll), | |
| 405 | + drawFontSize: printFontSize, | |
| 406 | + } | |
| 407 | +} | |
| 408 | + | |
| 409 | +function computeVisibleTextLines( | |
| 410 | + lines: string[], | |
| 411 | + layoutBoxH: number, | |
| 412 | + fittedLineHeight: number, | |
| 413 | + printFontSize: number, | |
| 414 | + isDateTimeType: boolean, | |
| 415 | +): string[] { | |
| 416 | + if (isDateTimeType) return lines.slice(0, 1) | |
| 417 | + if (layoutBoxH < printFontSize) return lines.slice(0, 1) | |
| 418 | + const maxLines = Math.min(lines.length, maxLinesForBox(layoutBoxH, fittedLineHeight, printFontSize)) | |
| 419 | + return lines.slice(0, maxLines) | |
| 420 | +} | |
| 421 | + | |
| 422 | +function drawStyledTextLine( | |
| 423 | + ctx: UniApp.CanvasContext, | |
| 424 | + line: string, | |
| 425 | + tx: number, | |
| 426 | + y: number, | |
| 427 | + fontSize: number, | |
| 428 | + align: string, | |
| 429 | + config: Record<string, any>, | |
| 430 | + fillColor: string, | |
| 431 | + emphasizeForPrint = false, | |
| 432 | + baseline: 'middle' | 'alphabetic' = 'alphabetic', | |
| 433 | +): void { | |
| 434 | + const family = resolveLabelEditorFontFamily(config) | |
| 435 | + const italic = readFontStyle(config) === 'italic' | |
| 436 | + const underline = readTextDecoration(config) === 'underline' | |
| 437 | + const useSkewFallback = italic && !isLabelEditorFontItalicLoaded(family) | |
| 438 | + const anyCtx = ctx as any | |
| 439 | + const lineWidth = approxTextWidth(line, fontSize) | |
| 440 | + if (typeof anyCtx.setTextBaseline === 'function') { | |
| 441 | + anyCtx.setTextBaseline(baseline) | |
| 442 | + } | |
| 443 | + | |
| 444 | + const drawFill = () => { | |
| 445 | + if (emphasizeForPrint) { | |
| 446 | + const stroke = Math.max(0.5, fontSize * 0.04) | |
| 447 | + for (const [ox, oy] of [[0, 0], [stroke, 0], [-stroke, 0], [0, stroke], [0, -stroke]] as const) { | |
| 448 | + ctx.fillText(line, tx + ox, y + oy) | |
| 449 | + } | |
| 450 | + return | |
| 451 | + } | |
| 452 | + ctx.fillText(line, tx, y) | |
| 453 | + } | |
| 454 | + | |
| 455 | + if (useSkewFallback && typeof anyCtx.save === 'function') { | |
| 456 | + anyCtx.save() | |
| 457 | + anyCtx.transform(1, 0, -0.25, 1, tx * 0.08, 0) | |
| 458 | + } | |
| 459 | + drawFill() | |
| 460 | + if (underline) { | |
| 461 | + let x1 = tx | |
| 462 | + let x2 = tx + lineWidth | |
| 463 | + if (align === 'center') { | |
| 464 | + x1 = tx - lineWidth / 2 | |
| 465 | + x2 = tx + lineWidth / 2 | |
| 466 | + } else if (align === 'right') { | |
| 467 | + x1 = tx - lineWidth | |
| 468 | + x2 = tx | |
| 469 | + } | |
| 470 | + const underlineY = computeUnderlineDrawY(y, fontSize, baseline) | |
| 471 | + ctx.setStrokeStyle(fillColor) | |
| 472 | + ctx.setLineWidth(Math.max(1, Math.round(fontSize * 0.06))) | |
| 473 | + ctx.beginPath() | |
| 474 | + ctx.moveTo(x1, underlineY) | |
| 475 | + ctx.lineTo(x2, underlineY) | |
| 476 | + ctx.stroke() | |
| 477 | + } | |
| 478 | + if (useSkewFallback && typeof anyCtx.restore === 'function') { | |
| 479 | + anyCtx.restore() | |
| 480 | + } | |
| 481 | +} | |
| 482 | + | |
| 65 | 483 | /** 按元素框宽度估算每行最大字符数(等宽近似,兼容中英文) */ |
| 66 | 484 | function maxCharsPerLine(innerWidthPx: number, fontSize: number): number { |
| 67 | 485 | if (innerWidthPx <= 4) return 8 |
| ... | ... | @@ -123,17 +541,57 @@ function wrapTextToWidth(text: string, maxChars: number): string[] { |
| 123 | 541 | return out.length ? out : [''] |
| 124 | 542 | } |
| 125 | 543 | |
| 544 | +function resolveDateTimePreviewText ( | |
| 545 | + element: SystemTemplateElementBase, | |
| 546 | + baseTime: Date = new Date(), | |
| 547 | +): string { | |
| 548 | + if (isPrintInputLiteralField(element)) { | |
| 549 | + return resolvePrintInputLiteralDisplay(element) | |
| 550 | + } | |
| 551 | + if (isWeekdayBarElement(element)) { | |
| 552 | + return applyConfigPrefix( | |
| 553 | + element.config || {}, | |
| 554 | + resolveWeekdayDisplayForElement(element, baseTime), | |
| 555 | + ) | |
| 556 | + } | |
| 557 | + const config = element.config || {} | |
| 558 | + const previewFormatted = cfgStr(config, ['__previewFormatted', '__PreviewFormatted'], '').trim() | |
| 559 | + if ( | |
| 560 | + previewFormatted && | |
| 561 | + !isCorruptedDateDisplayFormat(previewFormatted) && | |
| 562 | + !isStoredPrintInputOffsetPayload(previewFormatted) | |
| 563 | + ) { | |
| 564 | + return applyConfigPrefix(config, previewFormatted) | |
| 565 | + } | |
| 566 | + const sanitizedCfg = { ...config } as Record<string, unknown> | |
| 567 | + sanitizeDateElementConfig(element, sanitizedCfg) | |
| 568 | + try { | |
| 569 | + const live = resolveElementDateTimeDisplay({ ...element, config: sanitizedCfg }, baseTime) | |
| 570 | + if (live != null && live.trim() && !isCorruptedDateDisplayFormat(live) && !isStoredPrintInputOffsetPayload(live)) { | |
| 571 | + return applyConfigPrefix(sanitizedCfg, live) | |
| 572 | + } | |
| 573 | + } catch (err) { | |
| 574 | + console.warn('[labelPreview] resolveElementDateTimeDisplay failed', element.id, err) | |
| 575 | + } | |
| 576 | + return '' | |
| 577 | +} | |
| 578 | + | |
| 126 | 579 | function previewTextForElement(element: SystemTemplateElementBase, baseTime: Date = new Date()): string { |
| 127 | 580 | const type = String(element.type || '').toUpperCase() |
| 128 | 581 | const config = element.config || {} |
| 582 | + if (isPrintInputLiteralField(element)) { | |
| 583 | + return resolvePrintInputLiteralDisplay(element) | |
| 584 | + } | |
| 585 | + if (isWeekdayBarElement(element)) { | |
| 586 | + return applyConfigPrefix(config, resolveWeekdayDisplayForElement(element, baseTime)) | |
| 587 | + } | |
| 588 | + if (isEmployeeTemplateElement(element)) { | |
| 589 | + const cfgText = cfgStr(config, ['text', 'Text'], '').trim() | |
| 590 | + if (cfgText && cfgText.toLowerCase() !== 'text') return applyConfigPrefix(config, cfgText) | |
| 591 | + return applyConfigPrefix(config, getLoggedInEmployeeDisplayName()) | |
| 592 | + } | |
| 129 | 593 | if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { |
| 130 | - const live = resolveElementDateTimeDisplay(element, baseTime) | |
| 131 | - if (live != null && live.trim()) return live | |
| 132 | - /** applyLiveDateTimeFieldsToTemplate 已写入 config.text 时直接展示,避免二次 resolve 把 +N Days 重置为当天 */ | |
| 133 | - const baked = cfgStr(config, ['text', 'Text'], '').trim() | |
| 134 | - if (baked && isLikelyResolvedDateTimeLiteral(baked)) { | |
| 135 | - return applyConfigPrefix(config, baked) | |
| 136 | - } | |
| 594 | + return resolveDateTimePreviewText(element, baseTime) | |
| 137 | 595 | } |
| 138 | 596 | if (type === 'QRCODE') { |
| 139 | 597 | return cfgStr(config, ['data', 'Data', 'value', 'Value']) |
| ... | ... | @@ -149,18 +607,30 @@ function previewTextForElement(element: SystemTemplateElementBase, baseTime: Dat |
| 149 | 607 | const rawSel = config.selectedOptionValues ?? config.SelectedOptionValues |
| 150 | 608 | const arr = Array.isArray(rawSel) ? rawSel.map((x: unknown) => String(x)) : [] |
| 151 | 609 | const txt = cfgStr(config, ['text', 'Text'], '') |
| 152 | - if (arr.length > 0 && txt.trim()) return txt | |
| 153 | 610 | if (arr.length > 0) { |
| 154 | 611 | const joined = arr.join(', ') |
| 612 | + if (txt.trim() && arr.some((v) => txt.includes(v))) return txt | |
| 155 | 613 | return applyConfigPrefix(config, joined) |
| 156 | 614 | } |
| 157 | - const hint = txt.trim() || 'Select below' | |
| 158 | - return applyConfigPrefix(config, hint) | |
| 615 | + return '' | |
| 159 | 616 | } |
| 160 | 617 | if (vst === 'PRINT_INPUT' && !(inputType === 'options' || hasDict)) { |
| 618 | + if ( | |
| 619 | + inputType === 'date' || | |
| 620 | + inputType === 'datetime' || | |
| 621 | + isUniAppDateTimeOffsetField(element) | |
| 622 | + ) { | |
| 623 | + return resolveDateTimePreviewText(element, baseTime) | |
| 624 | + } | |
| 161 | 625 | let body = cfgStr(config, ['text', 'Text'], '') |
| 162 | 626 | if (!body.trim()) body = cfgStr(config, ['value', 'Value'], '') |
| 163 | 627 | if (!body.trim()) body = cfgStr(config, ['format', 'Format', 'placeholder', 'Placeholder'], '') |
| 628 | + const literalNumberOrText = | |
| 629 | + inputType === 'number' || | |
| 630 | + inputType === 'text' || | |
| 631 | + isPrintInputLiteralField(element) | |
| 632 | + if (!literalNumberOrText && isStoredPrintInputOffsetPayload(body)) return '' | |
| 633 | + if (isCorruptedDateDisplayFormat(body)) return '' | |
| 164 | 634 | const unit = String(config.unit ?? config.Unit ?? '').trim() |
| 165 | 635 | if (unit && body.trim() && !body.endsWith(unit)) body = `${body}${unit}` |
| 166 | 636 | return applyConfigPrefix(config, body) |
| ... | ... | @@ -179,6 +649,7 @@ function previewTextForElement(element: SystemTemplateElementBase, baseTime: Dat |
| 179 | 649 | 'defaultValue', |
| 180 | 650 | 'placeholder', |
| 181 | 651 | ]) |
| 652 | + if (isStoredPrintInputOffsetPayload(body) || isCorruptedDateDisplayFormat(body)) return '' | |
| 182 | 653 | return applyConfigPrefix(config, body) |
| 183 | 654 | } |
| 184 | 655 | |
| ... | ... | @@ -220,11 +691,13 @@ function drawBarcodeLikePreview( |
| 220 | 691 | w: number, |
| 221 | 692 | h: number, |
| 222 | 693 | value: string, |
| 223 | - options?: { orientation?: string; showText?: boolean } | |
| 694 | + options?: { orientation?: string; showText?: boolean; fontSize?: number }, | |
| 224 | 695 | ): void { |
| 225 | - const bw = Math.max(40, w || 140) | |
| 226 | - const bh = Math.max(28, h || 56) | |
| 696 | + const bw = Math.max(8, w || 140) | |
| 697 | + const bh = Math.max(8, h || 56) | |
| 227 | 698 | const showText = options?.showText !== false |
| 699 | + const fontSize = Math.max(8, Math.round(Number(options?.fontSize ?? 14) || 14)) | |
| 700 | + const labelReserve = showText ? Math.max(12, fontSize + 4) : 4 | |
| 228 | 701 | const pad = 2 |
| 229 | 702 | const modules = barcodeModulesFromValue(value) |
| 230 | 703 | const orientation = String(options?.orientation || 'horizontal').toLowerCase() |
| ... | ... | @@ -238,42 +711,42 @@ function drawBarcodeLikePreview( |
| 238 | 711 | ctx.setFillStyle('#111827') |
| 239 | 712 | |
| 240 | 713 | if (!isVertical) { |
| 241 | - const textH = showText && txt ? Math.max(10, Math.round(bh * 0.2)) : 0 | |
| 242 | - const barH = Math.max(10, bh - textH - pad * 2) | |
| 714 | + const textH = showText && txt ? labelReserve : 0 | |
| 715 | + const barH = Math.max(20, bh - textH - pad) | |
| 243 | 716 | const innerW = Math.max(8, bw - pad * 2) |
| 244 | 717 | const moduleW = innerW / modules.length |
| 245 | 718 | let cursor = x + pad |
| 246 | 719 | for (let i = 0; i < modules.length; i++) { |
| 247 | 720 | if (modules[i] === 1) { |
| 248 | - const rw = Math.max(0.7, moduleW * 0.72) | |
| 721 | + const rw = Math.max(0.7, moduleW * 0.85) | |
| 249 | 722 | ctx.fillRect(cursor, y + pad, rw, barH) |
| 250 | 723 | } |
| 251 | 724 | cursor += moduleW |
| 252 | 725 | } |
| 253 | 726 | if (showText && txt) { |
| 254 | - ctx.setFontSize(Math.max(9, Math.min(12, Math.round(textH * 0.8)))) | |
| 727 | + ctx.setFontSize(fontSize) | |
| 255 | 728 | ctx.setTextAlign('center') |
| 256 | - ctx.fillText(txt, x + bw / 2, y + bh - 2) | |
| 729 | + ctx.fillText(txt, x + bw / 2, y + bh - Math.max(2, Math.round(fontSize * 0.2))) | |
| 257 | 730 | ctx.setTextAlign('left') |
| 258 | 731 | } |
| 259 | 732 | return |
| 260 | 733 | } |
| 261 | 734 | |
| 262 | 735 | // vertical:条码在左,data 竖排在右 |
| 263 | - const textBandW = showText && txt ? Math.max(10, Math.round(bw * 0.18)) : 0 | |
| 264 | - const barW = Math.max(10, bw - textBandW - pad * 2) | |
| 265 | - const innerH = Math.max(10, bh - pad * 2) | |
| 736 | + const textBandW = showText && txt ? Math.max(14, Math.round(fontSize + 6)) : 0 | |
| 737 | + const barW = Math.max(12, bw - textBandW - pad * 2) | |
| 738 | + const innerH = Math.max(12, bh - pad * 2) | |
| 266 | 739 | const moduleH = innerH / modules.length |
| 267 | 740 | let cursorY = y + pad |
| 268 | 741 | for (let i = 0; i < modules.length; i++) { |
| 269 | 742 | if (modules[i] === 1) { |
| 270 | - const rh = Math.max(0.7, moduleH * 0.72) | |
| 743 | + const rh = Math.max(0.7, moduleH * 0.85) | |
| 271 | 744 | ctx.fillRect(x + pad, cursorY, barW, rh) |
| 272 | 745 | } |
| 273 | 746 | cursorY += moduleH |
| 274 | 747 | } |
| 275 | 748 | if (showText && txt) { |
| 276 | - const font = Math.max(9, Math.min(11, Math.floor(textBandW * 0.75))) | |
| 749 | + const font = fontSize | |
| 277 | 750 | const cx = x + bw - textBandW / 2 |
| 278 | 751 | const cy = y + bh / 2 |
| 279 | 752 | const anyCtx = ctx as any |
| ... | ... | @@ -350,33 +823,230 @@ function drawQrCodePreview( |
| 350 | 823 | } |
| 351 | 824 | } |
| 352 | 825 | |
| 353 | -function nutritionFixedField(cfg: Record<string, any>, key: string, field: 'value' | 'unit'): string { | |
| 354 | - const directKey = field === 'value' ? key : `${key}Unit` | |
| 355 | - const direct = cfg[directKey] | |
| 356 | - if (direct != null && String(direct).trim() !== '') return String(direct).trim() | |
| 357 | - const rows = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Array<Record<string, unknown>>) : [] | |
| 358 | - const row = rows.find((item) => String(item.key ?? '').trim() === key) | |
| 359 | - return String(row?.[field] ?? '').trim() | |
| 826 | + | |
| 827 | +/** 双下划线两线间距(px),与 Web NutritionFactsPanel 一致 */ | |
| 828 | +const NUTRITION_DOUBLE_LINE_GAP = 3 | |
| 829 | + | |
| 830 | +function drawNutritionDivider( | |
| 831 | + ctx: UniApp.CanvasContext, | |
| 832 | + x1: number, | |
| 833 | + x2: number, | |
| 834 | + y: number, | |
| 835 | + kind: NutritionDivider, | |
| 836 | +): number { | |
| 837 | + if (kind === 'none') return 0 | |
| 838 | + ctx.setStrokeStyle('#111827') | |
| 839 | + ctx.setLineWidth(1) | |
| 840 | + if (kind === 'double') { | |
| 841 | + ctx.beginPath() | |
| 842 | + ctx.moveTo(x1, y) | |
| 843 | + ctx.lineTo(x2, y) | |
| 844 | + ctx.stroke() | |
| 845 | + const y2 = y + 1 + NUTRITION_DOUBLE_LINE_GAP | |
| 846 | + ctx.beginPath() | |
| 847 | + ctx.moveTo(x1, y2) | |
| 848 | + ctx.lineTo(x2, y2) | |
| 849 | + ctx.stroke() | |
| 850 | + return 1 + NUTRITION_DOUBLE_LINE_GAP + 1 + 2 | |
| 851 | + } | |
| 852 | + ctx.beginPath() | |
| 853 | + ctx.moveTo(x1, y) | |
| 854 | + ctx.lineTo(x2, y) | |
| 855 | + ctx.stroke() | |
| 856 | + return 2 | |
| 360 | 857 | } |
| 361 | 858 | |
| 362 | -function nutritionExtraRows(cfg: Record<string, any>): Array<{ name: string; value: string; unit: string }> { | |
| 363 | - const raw = cfg.extraNutrients | |
| 364 | - if (!Array.isArray(raw)) return [] | |
| 365 | - return raw.map((item) => { | |
| 366 | - const row = (item || {}) as Record<string, unknown> | |
| 367 | - return { | |
| 368 | - name: String(row.name ?? '').trim(), | |
| 369 | - value: String(row.value ?? '').trim(), | |
| 370 | - unit: String(row.unit ?? '').trim(), | |
| 859 | +function drawNutritionFactsOnCanvas( | |
| 860 | + ctx: UniApp.CanvasContext, | |
| 861 | + config: Record<string, any>, | |
| 862 | + boxX: number, | |
| 863 | + boxY: number, | |
| 864 | + boxW: number, | |
| 865 | + boxH: number, | |
| 866 | +): void { | |
| 867 | + const model = buildNutritionFactsViewModel(config) | |
| 868 | + const pad = 6 | |
| 869 | + const leftX = boxX + pad | |
| 870 | + const rightX = boxX + boxW - pad | |
| 871 | + const amountColRight = rightX - NUTRITION_PCT_COL_WIDTH | |
| 872 | + const amountX = amountColRight - NUTRITION_AMOUNT_COL_WIDTH / 2 | |
| 873 | + const pctX = rightX | |
| 874 | + const maxY = boxY + boxH - pad | |
| 875 | + let cursorY = boxY + pad | |
| 876 | + const titleSize = Math.max(11, Math.min(18, model.titleFontSize)) | |
| 877 | + const bodySize = NUTRITION_BODY_FONT_SIZE | |
| 878 | + const footerSize = Math.max(8, Math.round(bodySize * 0.67)) | |
| 879 | + /** 正文用 Roboto,避免 FreightSans Bold 导致整表加粗(与 Web 一致) */ | |
| 880 | + const bodyCfg = { ...config, fontFamily: 'Roboto', FontFamily: 'Roboto' } | |
| 881 | + | |
| 882 | + const rowStep = (fs: number) => fs + 3 | |
| 883 | + | |
| 884 | + const drawLine = (label: string, value: string, fs: number, labelBold = false) => { | |
| 885 | + const f = Math.max(8, Math.round(fs)) | |
| 886 | + const lh = rowStep(f) | |
| 887 | + if (cursorY + lh > maxY) return false | |
| 888 | + ctx.setFillStyle('#111827') | |
| 889 | + ctx.setFontSize(f) | |
| 890 | + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: labelBold ? 'bold' : 'normal' }, f) | |
| 891 | + ctx.setTextAlign('left') | |
| 892 | + ctx.fillText(label, leftX, cursorY + f) | |
| 893 | + if (value) { | |
| 894 | + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, f) | |
| 895 | + ctx.setTextAlign('right') | |
| 896 | + ctx.fillText(value, rightX, cursorY + f) | |
| 371 | 897 | } |
| 372 | - }) | |
| 898 | + cursorY += lh | |
| 899 | + return true | |
| 900 | + } | |
| 901 | + | |
| 902 | + const drawNutrientRow = ( | |
| 903 | + label: string, | |
| 904 | + amount: string, | |
| 905 | + pct: string, | |
| 906 | + fs: number, | |
| 907 | + labelBold: boolean, | |
| 908 | + indent: boolean, | |
| 909 | + divider: NutritionDivider, | |
| 910 | + ) => { | |
| 911 | + const f = Math.max(8, Math.round(fs)) | |
| 912 | + const lh = rowStep(f) | |
| 913 | + if (cursorY + lh > maxY) return false | |
| 914 | + const labelX = leftX + (indent ? 10 : 0) | |
| 915 | + ctx.setFillStyle('#111827') | |
| 916 | + ctx.setFontSize(f) | |
| 917 | + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: labelBold ? 'bold' : 'normal' }, f) | |
| 918 | + ctx.setTextAlign('left') | |
| 919 | + ctx.fillText(label, labelX, cursorY + f) | |
| 920 | + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, f) | |
| 921 | + if (amount) { | |
| 922 | + ctx.setTextAlign('center') | |
| 923 | + ctx.fillText(amount, amountX, cursorY + f) | |
| 924 | + } | |
| 925 | + if (pct) { | |
| 926 | + ctx.setTextAlign('right') | |
| 927 | + ctx.fillText(pct, pctX, cursorY + f) | |
| 928 | + } | |
| 929 | + cursorY += lh | |
| 930 | + if (divider !== 'none') { | |
| 931 | + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, divider) | |
| 932 | + } | |
| 933 | + return true | |
| 934 | + } | |
| 935 | + | |
| 936 | + const drawFooterNote = () => { | |
| 937 | + if (cursorY + footerSize + 6 > maxY) return | |
| 938 | + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'thin') | |
| 939 | + ctx.setFontSize(footerSize) | |
| 940 | + ctx.setTextAlign('left') | |
| 941 | + const parts = DEFAULT_NUTRITION_FOOTER_NOTE.split(/(\b2000\b)/) | |
| 942 | + let fx = leftX | |
| 943 | + for (const part of parts) { | |
| 944 | + if (!part) continue | |
| 945 | + applyCanvasFontFromConfig( | |
| 946 | + ctx, | |
| 947 | + { ...bodyCfg, fontWeight: part === '2000' ? 'bold' : 'normal' }, | |
| 948 | + footerSize, | |
| 949 | + ) | |
| 950 | + ctx.fillText(part, fx, cursorY + footerSize) | |
| 951 | + fx += approxTextWidth(part, footerSize) | |
| 952 | + } | |
| 953 | + cursorY += footerSize + 2 | |
| 954 | + } | |
| 955 | + | |
| 956 | + ctx.setFillStyle('#111827') | |
| 957 | + ctx.setFontSize(Math.round(titleSize)) | |
| 958 | + applyCanvasFontFromConfig(ctx, { ...config, fontWeight: 'bold' }, titleSize) | |
| 959 | + ctx.setTextAlign('left') | |
| 960 | + ctx.fillText('Nutrition Facts', leftX, cursorY + titleSize) | |
| 961 | + cursorY += titleSize + 1 | |
| 962 | + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double') | |
| 963 | + | |
| 964 | + drawLine(model.servingsLabel, model.servingsValue, bodySize) | |
| 965 | + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'thin') | |
| 966 | + drawLine(model.servingSizeLabel, model.servingSizeValue, bodySize) | |
| 967 | + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double') | |
| 968 | + | |
| 969 | + if (cursorY + bodySize + 3 <= maxY) { | |
| 970 | + ctx.setFontSize(bodySize) | |
| 971 | + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'bold' }, bodySize) | |
| 972 | + ctx.setTextAlign('left') | |
| 973 | + ctx.fillText(model.caloriesLabel, leftX, cursorY + bodySize) | |
| 974 | + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, bodySize) | |
| 975 | + ctx.setTextAlign('right') | |
| 976 | + ctx.fillText(model.caloriesAmountText || model.caloriesValue, rightX, cursorY + bodySize) | |
| 977 | + cursorY += bodySize + 1 | |
| 978 | + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double') | |
| 979 | + } | |
| 980 | + | |
| 981 | + for (const row of model.rows) { | |
| 982 | + if ( | |
| 983 | + !drawNutrientRow( | |
| 984 | + row.label, | |
| 985 | + row.amountText, | |
| 986 | + row.dailyValueText, | |
| 987 | + bodySize, | |
| 988 | + row.labelBold, | |
| 989 | + row.indent, | |
| 990 | + row.dividerAfter, | |
| 991 | + ) | |
| 992 | + ) { | |
| 993 | + break | |
| 994 | + } | |
| 995 | + } | |
| 996 | + | |
| 997 | + drawFooterNote() | |
| 998 | +} | |
| 999 | + | |
| 1000 | +function strokeTemplatePaperBorder ( | |
| 1001 | + ctx: UniApp.CanvasContext, | |
| 1002 | + template: SystemLabelTemplate, | |
| 1003 | + cw: number, | |
| 1004 | + ch: number | |
| 1005 | +) { | |
| 1006 | + const border = String(template.border || '').toLowerCase() | |
| 1007 | + if (border !== 'line' && border !== 'dotted') return | |
| 1008 | + ctx.setStrokeStyle('#374151') | |
| 1009 | + ctx.setLineWidth(2) | |
| 1010 | + const w = Math.max(0, cw - 1) | |
| 1011 | + const h = Math.max(0, ch - 1) | |
| 1012 | + if (border === 'dotted' && typeof (ctx as any).setLineDash === 'function') { | |
| 1013 | + ;(ctx as any).setLineDash([4, 3], 0) | |
| 1014 | + ctx.strokeRect(1, 1, w - 1, h - 1) | |
| 1015 | + ;(ctx as any).setLineDash([], 0) | |
| 1016 | + } else { | |
| 1017 | + ctx.strokeRect(1, 1, w - 1, h - 1) | |
| 1018 | + } | |
| 1019 | +} | |
| 1020 | + | |
| 1021 | +/** 元素级边框:须在背景/文字之后绘制,避免被 invert 黑底盖住 */ | |
| 1022 | +function strokeElementBorder( | |
| 1023 | + ctx: UniApp.CanvasContext, | |
| 1024 | + x: number, | |
| 1025 | + y: number, | |
| 1026 | + w: number, | |
| 1027 | + h: number, | |
| 1028 | + border: string | undefined, | |
| 1029 | +) { | |
| 1030 | + const line = String(border || '').toLowerCase() | |
| 1031 | + if (line !== 'line' && line !== 'solid' && line !== 'dotted') return | |
| 1032 | + ctx.setStrokeStyle(line === 'dotted' ? '#9ca3af' : '#111827') | |
| 1033 | + ctx.setLineWidth(1) | |
| 1034 | + if (line === 'dotted' && typeof (ctx as any).setLineDash === 'function') { | |
| 1035 | + ;(ctx as any).setLineDash([3, 3], 0) | |
| 1036 | + ctx.strokeRect(x, y, w, h) | |
| 1037 | + ;(ctx as any).setLineDash([], 0) | |
| 1038 | + } else { | |
| 1039 | + ctx.strokeRect(x, y, w, h) | |
| 1040 | + } | |
| 373 | 1041 | } |
| 374 | 1042 | |
| 375 | -function nutritionValueWithLessThan(value: string, unit: string): string { | |
| 376 | - const v = String(value || '').trim() | |
| 377 | - const u = String(unit || '').trim() | |
| 378 | - if (!v && !u) return '' | |
| 379 | - return `<${v}${u ? ` ${u}` : ''}` | |
| 1043 | +/** 横打:与 Web printContentLayerStyle rotate(90deg) 一致,纸张尺寸不变 */ | |
| 1044 | +function applyPrintContentRotation(ctx: UniApp.CanvasContext, cw: number, ch: number): void { | |
| 1045 | + const cx = cw / 2 | |
| 1046 | + const cy = ch / 2 | |
| 1047 | + ctx.translate(cx, cy) | |
| 1048 | + ctx.rotate(Math.PI / 2) | |
| 1049 | + ctx.translate(-cx, -cy) | |
| 380 | 1050 | } |
| 381 | 1051 | |
| 382 | 1052 | /** 与屏幕预览 / 位图打印共用绘制逻辑(坐标系:设计宽 cw × ch,ctx 已 scale) */ |
| ... | ... | @@ -386,18 +1056,32 @@ function runLabelPreviewCanvasDraw( |
| 386 | 1056 | template: SystemLabelTemplate, |
| 387 | 1057 | cw: number, |
| 388 | 1058 | ch: number, |
| 389 | - scale: number | |
| 1059 | + scale: number, | |
| 1060 | + drawOptions: { forPrint?: boolean; baseTime?: Date } = {}, | |
| 390 | 1061 | ): Promise<void> { |
| 1062 | + const forPrint = drawOptions.forPrint === true | |
| 1063 | + const baseTime = drawOptions.baseTime ?? new Date() | |
| 391 | 1064 | const sorted = sortElementsForPreview(template.elements || []) |
| 1065 | + const rotateContent = normalizeTemplatePrintOrientation(template.printOrientation) === 'horizontal' | |
| 392 | 1066 | |
| 393 | - return new Promise((resolve) => { | |
| 1067 | + return ensureLabelEditorFontsForTemplate(template).then( | |
| 1068 | + () => | |
| 1069 | + new Promise((resolve) => { | |
| 394 | 1070 | const ctx = uni.createCanvasContext(canvasId, componentInstance) |
| 395 | 1071 | ctx.setFillStyle('#ffffff') |
| 396 | 1072 | ctx.scale(scale, scale) |
| 397 | 1073 | ctx.fillRect(0, 0, cw, ch) |
| 1074 | + if (rotateContent) { | |
| 1075 | + ctx.save() | |
| 1076 | + applyPrintContentRotation(ctx, cw, ch) | |
| 1077 | + } | |
| 398 | 1078 | |
| 399 | 1079 | const drawRest = (index: number) => { |
| 400 | 1080 | if (index >= sorted.length) { |
| 1081 | + if (rotateContent) { | |
| 1082 | + ctx.restore() | |
| 1083 | + } | |
| 1084 | + strokeTemplatePaperBorder(ctx, template, cw, ch) | |
| 401 | 1085 | ctx.draw(false, () => resolve()) |
| 402 | 1086 | return |
| 403 | 1087 | } |
| ... | ... | @@ -411,110 +1095,51 @@ function runLabelPreviewCanvasDraw( |
| 411 | 1095 | const h = Math.max(0, Number(el.height) || 0) |
| 412 | 1096 | |
| 413 | 1097 | const next = () => drawRest(index + 1) |
| 1098 | + const finishElement = () => { | |
| 1099 | + strokeElementBorder(ctx, x, y, w, h, readElementBorder(el)) | |
| 1100 | + next() | |
| 1101 | + } | |
| 414 | 1102 | |
| 415 | 1103 | if (type === 'IMAGE' || type === 'LOGO') { |
| 416 | 1104 | const src = resolveMediaUrlForApp(cfgStr(config, ['src', 'url', 'Src', 'Url'])) |
| 1105 | + const boxW = w || 80 | |
| 1106 | + const boxH = h || 40 | |
| 1107 | + const scaleMode = readImageScaleMode(config) | |
| 417 | 1108 | if (src) { |
| 418 | 1109 | uni.getImageInfo({ |
| 419 | 1110 | src, |
| 420 | 1111 | success: (info) => { |
| 421 | 1112 | try { |
| 422 | - ctx.drawImage(info.path, x, y, w || info.width, h || info.height) | |
| 1113 | + const rect = computeImageDrawRect( | |
| 1114 | + boxW, | |
| 1115 | + boxH, | |
| 1116 | + Number(info.width) || 0, | |
| 1117 | + Number(info.height) || 0, | |
| 1118 | + scaleMode, | |
| 1119 | + ) | |
| 1120 | + ctx.drawImage(info.path, x + rect.dx, y + rect.dy, rect.dw, rect.dh) | |
| 423 | 1121 | } catch (_) { |
| 424 | 1122 | ctx.setStrokeStyle('#cccccc') |
| 425 | 1123 | ctx.setLineWidth(1) |
| 426 | - ctx.strokeRect(x, y, w || 80, h || 40) | |
| 1124 | + ctx.strokeRect(x, y, boxW, boxH) | |
| 427 | 1125 | } |
| 428 | - next() | |
| 1126 | + finishElement() | |
| 429 | 1127 | }, |
| 430 | 1128 | fail: () => { |
| 431 | 1129 | ctx.setStrokeStyle('#cccccc') |
| 432 | - ctx.strokeRect(x, y, w || 80, h || 40) | |
| 433 | - next() | |
| 1130 | + ctx.strokeRect(x, y, boxW, boxH) | |
| 1131 | + finishElement() | |
| 434 | 1132 | }, |
| 435 | 1133 | }) |
| 436 | 1134 | return |
| 437 | 1135 | } |
| 438 | - next() | |
| 1136 | + finishElement() | |
| 439 | 1137 | return |
| 440 | 1138 | } |
| 441 | 1139 | |
| 442 | 1140 | if (type === 'NUTRITION') { |
| 443 | - const bw = Math.max(40, w || 120) | |
| 444 | - const bh = Math.max(36, h || 96) | |
| 445 | - const pad = 3 | |
| 446 | - /** 数值列更贴右边框,与原生 NUTRITION_VALUE_RIGHT_MARGIN 一致 */ | |
| 447 | - const rightX = x + bw - 1 | |
| 448 | - const maxY = y + bh - 2 | |
| 449 | - const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16)) | |
| 450 | - const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72))) | |
| 451 | - let cursorY = y + pad | |
| 452 | - | |
| 453 | - const servingsPerContainer = String(config.servingsPerContainer ?? config.ServingsPerContainer ?? '').trim() | |
| 454 | - const servingSize = String(config.servingSize ?? config.ServingSize ?? '').trim() | |
| 455 | - const calories = String(config.calories ?? config.Calories ?? nutritionFixedField(config, 'calories', 'value') ?? '').trim() | |
| 456 | - const rows = [ | |
| 457 | - ...NUTRITION_FIXED_ITEMS.map((item) => { | |
| 458 | - const value = nutritionFixedField(config, item.key, 'value') | |
| 459 | - const unit = nutritionFixedField(config, item.key, 'unit') | |
| 460 | - if (!value) return null | |
| 461 | - return { label: item.label, value: nutritionValueWithLessThan(value, unit) } | |
| 462 | - }).filter(Boolean) as Array<{ label: string; value: string }>, | |
| 463 | - ...nutritionExtraRows(config) | |
| 464 | - .filter((r) => r.value) | |
| 465 | - .map((r) => ({ | |
| 466 | - label: r.name || 'Other', | |
| 467 | - value: nutritionValueWithLessThan(r.value, r.unit), | |
| 468 | - })), | |
| 469 | - ] | |
| 470 | - | |
| 471 | - const drawPair = (label: string, value: string, fs: number, bold = false): boolean => { | |
| 472 | - const f = Math.max(8, Math.round(fs)) | |
| 473 | - const lh = f + 2 | |
| 474 | - if (cursorY + lh > maxY) return false | |
| 475 | - ctx.setFillStyle('#111827') | |
| 476 | - ctx.setFontSize(f) | |
| 477 | - ctx.setTextAlign('left') | |
| 478 | - ctx.fillText(label, x + pad, cursorY + f) | |
| 479 | - if (bold) ctx.fillText(label, x + pad + 0.5, cursorY + f) | |
| 480 | - if (value) { | |
| 481 | - ctx.setTextAlign('right') | |
| 482 | - ctx.fillText(value, rightX, cursorY + f) | |
| 483 | - if (bold) ctx.fillText(value, rightX + 0.5, cursorY + f) | |
| 484 | - ctx.setTextAlign('left') | |
| 485 | - } | |
| 486 | - cursorY += lh | |
| 487 | - return true | |
| 488 | - } | |
| 489 | - | |
| 490 | - ctx.setFillStyle('#ffffff') | |
| 491 | - ctx.fillRect(x, y, bw, bh) | |
| 492 | - ctx.setStrokeStyle('#111827') | |
| 493 | - ctx.setLineWidth(1) | |
| 494 | - ctx.strokeRect(x, y, bw, bh) | |
| 495 | - | |
| 496 | - if (cursorY + titleSize + 2 <= maxY) { | |
| 497 | - ctx.setFillStyle('#111827') | |
| 498 | - ctx.setFontSize(Math.round(titleSize)) | |
| 499 | - ctx.setTextAlign('left') | |
| 500 | - ctx.fillText('Nutrition Facts', x + pad, cursorY + Math.round(titleSize)) | |
| 501 | - cursorY += Math.round(titleSize) + 2 | |
| 502 | - ctx.setLineWidth(1) | |
| 503 | - ctx.beginPath() | |
| 504 | - ctx.moveTo(x + pad, cursorY) | |
| 505 | - ctx.lineTo(rightX, cursorY) | |
| 506 | - ctx.stroke() | |
| 507 | - cursorY += 2 | |
| 508 | - } | |
| 509 | - | |
| 510 | - if (calories) drawPair('Calories', nutritionValueWithLessThan(calories, ''), Math.max(bodySize, 9), true) | |
| 511 | - if (servingsPerContainer) drawPair('Servings Per Container', servingsPerContainer, bodySize) | |
| 512 | - if (servingSize) drawPair('Serving Size', servingSize, bodySize) | |
| 513 | - for (const row of rows) { | |
| 514 | - if (!drawPair(row.label, row.value, bodySize, true)) break | |
| 515 | - } | |
| 516 | - | |
| 517 | - next() | |
| 1141 | + drawNutritionFactsOnCanvas(ctx, config, x, y, Math.max(40, w || 220), Math.max(80, h || 280)) | |
| 1142 | + finishElement() | |
| 518 | 1143 | return |
| 519 | 1144 | } |
| 520 | 1145 | |
| ... | ... | @@ -537,16 +1162,23 @@ function runLabelPreviewCanvasDraw( |
| 537 | 1162 | } |
| 538 | 1163 | |
| 539 | 1164 | if (type === 'BARCODE' && d) { |
| 540 | - const orientation = cfgStr(config, ['orientation', 'Orientation'], 'horizontal') | |
| 1165 | + const elementRotation = readElementRotation(el) | |
| 1166 | + const configOrientation = cfgStr(config, ['orientation', 'Orientation'], 'horizontal').toLowerCase() | |
| 1167 | + const orientation = elementRotation === 'vertical' ? 'vertical' : configOrientation | |
| 541 | 1168 | const showText = String(config.showText ?? config.ShowText ?? 'true').toLowerCase() !== 'false' |
| 542 | - drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText }) | |
| 543 | - next() | |
| 1169 | + const barcodeFontSize = Number(config.fontSize ?? config.FontSize ?? 14) || 14 | |
| 1170 | + drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { | |
| 1171 | + orientation, | |
| 1172 | + showText, | |
| 1173 | + fontSize: barcodeFontSize, | |
| 1174 | + }) | |
| 1175 | + finishElement() | |
| 544 | 1176 | return |
| 545 | 1177 | } |
| 546 | 1178 | |
| 547 | 1179 | if (type === 'QRCODE' && d && !storedValueLooksLikeImagePath(d)) { |
| 548 | 1180 | drawQrCodePreview(ctx, x, y, w || 96, h || 96, d, cfgStr(config, ['errorLevel', 'ErrorLevel'], 'M')) |
| 549 | - next() | |
| 1181 | + finishElement() | |
| 550 | 1182 | return |
| 551 | 1183 | } |
| 552 | 1184 | |
| ... | ... | @@ -564,11 +1196,11 @@ function runLabelPreviewCanvasDraw( |
| 564 | 1196 | } catch (_) { |
| 565 | 1197 | drawQrBarcodePlaceholder() |
| 566 | 1198 | } |
| 567 | - next() | |
| 1199 | + finishElement() | |
| 568 | 1200 | }, |
| 569 | 1201 | fail: () => { |
| 570 | 1202 | drawQrBarcodePlaceholder() |
| 571 | - next() | |
| 1203 | + finishElement() | |
| 572 | 1204 | }, |
| 573 | 1205 | }) |
| 574 | 1206 | return |
| ... | ... | @@ -576,62 +1208,180 @@ function runLabelPreviewCanvasDraw( |
| 576 | 1208 | } |
| 577 | 1209 | |
| 578 | 1210 | drawQrBarcodePlaceholder() |
| 579 | - next() | |
| 1211 | + finishElement() | |
| 580 | 1212 | return |
| 581 | 1213 | } |
| 582 | 1214 | |
| 583 | - const line = String(el.border || '').toLowerCase() | |
| 584 | - if (line === 'line' || line === 'solid') { | |
| 585 | - ctx.setStrokeStyle('#111827') | |
| 586 | - ctx.setLineWidth(1) | |
| 587 | - ctx.strokeRect(x, y, w, h) | |
| 588 | - } else if (line === 'dotted') { | |
| 589 | - ctx.setStrokeStyle('#9ca3af') | |
| 590 | - ctx.setLineWidth(1) | |
| 591 | - if (typeof (ctx as any).setLineDash === 'function') { | |
| 592 | - ;(ctx as any).setLineDash([3, 3], 0) | |
| 593 | - ctx.strokeRect(x, y, w, h) | |
| 594 | - ;(ctx as any).setLineDash([], 0) | |
| 595 | - } else { | |
| 596 | - ctx.strokeRect(x, y, w, h) | |
| 1215 | + const weekdayBar = isWeekdayBarElement(el) | |
| 1216 | + let finalText = '' | |
| 1217 | + if (weekdayBar) { | |
| 1218 | + try { | |
| 1219 | + finalText = resolveWeekdayDisplayForElement(el, baseTime) | |
| 1220 | + } catch (err) { | |
| 1221 | + console.warn('[labelPreview] resolveWeekdayDisplayForElement failed', el.id, err) | |
| 1222 | + } | |
| 1223 | + } else { | |
| 1224 | + try { | |
| 1225 | + finalText = previewTextForElement(el, baseTime) | |
| 1226 | + } catch (err) { | |
| 1227 | + console.warn('[labelPreview] previewTextForElement failed', el.id, err) | |
| 1228 | + } | |
| 1229 | + if (finalText && isCorruptedDateDisplayFormat(finalText)) { | |
| 1230 | + finalText = '' | |
| 597 | 1231 | } |
| 598 | 1232 | } |
| 1233 | + if ((finalText || weekdayBar) && !isGraphicOnlyType(type)) { | |
| 1234 | + if (!finalText && weekdayBar) { | |
| 1235 | + finalText = resolveWeekdayDisplayForElement(el, baseTime) | |
| 1236 | + } | |
| 1237 | + const rotationDegrees = elementRotationDegrees(el as any) | |
| 1238 | + const drawAt = (bx: number, by: number, bw: number, bh: number) => { | |
| 1239 | + const anyCtx = ctx as any | |
| 1240 | + if (typeof anyCtx.save === 'function') anyCtx.save() | |
| 1241 | + if (typeof anyCtx.beginPath === 'function' && typeof anyCtx.rect === 'function') { | |
| 1242 | + anyCtx.beginPath() | |
| 1243 | + anyCtx.rect(bx, by, bw, bh) | |
| 1244 | + if (typeof anyCtx.clip === 'function') anyCtx.clip() | |
| 1245 | + } | |
| 1246 | + | |
| 1247 | + const fontSize = readFontSize(config) | |
| 1248 | + const invertedBar = readInvertColors(config) | |
| 1249 | + const printFontSize = invertedBar && forPrint ? fontSize + Math.max(1, Math.round(fontSize * 0.08)) : fontSize | |
| 1250 | + const align = readTextAlign(config) | |
| 1251 | + const fillColor = invertedBar ? '#ffffff' : readFillColor(config) | |
| 1252 | + /** 与 Web AlignedTextBox 的 Tailwind px-1 一致:仅水平 4px,垂直无 padding */ | |
| 1253 | + const hPad = 4 | |
| 1254 | + const innerW = Math.max(0, bw - hPad * 2) | |
| 1255 | + const innerH = Math.max(printFontSize, bh) | |
| 1256 | + let tx = bx + hPad | |
| 1257 | + if (align === 'center') tx = bx + bw / 2 | |
| 1258 | + else if (align === 'right') tx = bx + bw - hPad | |
| 1259 | + const isDateTimeType = type === 'DATE' || type === 'TIME' || type === 'DURATION' || weekdayBar | |
| 1260 | + const invertedStatic = invertedBar && !weekdayBar | |
| 1261 | + const breakAll = shouldBreakAllTextWrap(type, invertedStatic) | |
| 1262 | + const layoutBoxH = invertedBar ? bh : innerH | |
| 1263 | + const baseLineHeight = invertedStatic | |
| 1264 | + ? invertedStaticLineHeight(printFontSize) | |
| 1265 | + : lineHeightForTextElement(printFontSize, isDateTimeType) | |
| 1266 | + const { lines, drawFontSize } = resolveTextLinesForDraw( | |
| 1267 | + ctx, | |
| 1268 | + finalText, | |
| 1269 | + innerW, | |
| 1270 | + layoutBoxH, | |
| 1271 | + printFontSize, | |
| 1272 | + baseLineHeight, | |
| 1273 | + config, | |
| 1274 | + { breakAll, isDateTimeType }, | |
| 1275 | + ) | |
| 1276 | + ctx.setFontSize(drawFontSize) | |
| 1277 | + applyCanvasFontFromConfig(ctx, config, drawFontSize) | |
| 1278 | + const fontWeight = invertedBar && forPrint ? 'bold' : readCanvasFontWeight(config) | |
| 1279 | + if (typeof anyCtx.setFontWeight === 'function') { | |
| 1280 | + anyCtx.setFontWeight(fontWeight === 'bold' ? 'bold' : 'normal') | |
| 1281 | + } | |
| 1282 | + ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left') | |
| 1283 | + const fittedLineHeight = | |
| 1284 | + isDateTimeType && !invertedBar && bh > 0 | |
| 1285 | + ? Math.min( | |
| 1286 | + invertedStatic | |
| 1287 | + ? invertedStaticLineHeight(drawFontSize) | |
| 1288 | + : lineHeightForTextElement(drawFontSize, isDateTimeType), | |
| 1289 | + bh, | |
| 1290 | + ) | |
| 1291 | + : invertedStatic | |
| 1292 | + ? invertedStaticLineHeight(drawFontSize) | |
| 1293 | + : lineHeightForTextElement(drawFontSize, isDateTimeType) | |
| 1294 | + const visibleLines = computeVisibleTextLines( | |
| 1295 | + lines, | |
| 1296 | + layoutBoxH, | |
| 1297 | + fittedLineHeight, | |
| 1298 | + drawFontSize, | |
| 1299 | + isDateTimeType, | |
| 1300 | + ) | |
| 1301 | + const verticalAlign = resolveDrawVerticalAlign(config, el, { invertedBar, weekdayBar }) | |
| 1302 | + const weekdayStrip = invertedBar && weekdayBar | |
| 1303 | + ? layoutInvertedWeekdayStrip(by, bh, printFontSize, verticalAlign) | |
| 1304 | + : null | |
| 1305 | + | |
| 1306 | + if (weekdayStrip) { | |
| 1307 | + ctx.setFillStyle('#000000') | |
| 1308 | + ctx.fillRect(bx + hPad, weekdayStrip.stripTop, innerW, weekdayStrip.stripHeight) | |
| 1309 | + ctx.setFillStyle('#ffffff') | |
| 1310 | + } else if (invertedBar) { | |
| 1311 | + ctx.setFillStyle('#000000') | |
| 1312 | + /** 黑底铺满元素框(与 Web invert 控件视觉尺寸一致),避免只画文字行高导致「控件不完整」 */ | |
| 1313 | + ctx.fillRect(bx + hPad, by, innerW, bh) | |
| 1314 | + ctx.setFillStyle('#ffffff') | |
| 1315 | + } else { | |
| 1316 | + ctx.setFillStyle(fillColor) | |
| 1317 | + } | |
| 599 | 1318 | |
| 600 | - const text = previewTextForElement(el) | |
| 601 | - if (text && !isGraphicOnlyType(type)) { | |
| 602 | - const fontSize = readFontSize(config) | |
| 603 | - const color = readFillColor(config) | |
| 604 | - ctx.setFillStyle(color) | |
| 605 | - ctx.setFontSize(fontSize) | |
| 606 | - const fontWeight = String(config.fontWeight ?? config.FontWeight ?? 'normal').toLowerCase() | |
| 607 | - if (typeof (ctx as any).setFontWeight === 'function') { | |
| 608 | - ;(ctx as any).setFontWeight(fontWeight === 'bold' || fontWeight === '700' ? 'bold' : 'normal') | |
| 1319 | + visibleLines.forEach((ln, li) => { | |
| 1320 | + let textY: number | |
| 1321 | + let baseline: 'middle' | 'alphabetic' = 'alphabetic' | |
| 1322 | + if (weekdayStrip) { | |
| 1323 | + textY = weekdayStrip.textY + li * weekdayStrip.stripHeight | |
| 1324 | + } else { | |
| 1325 | + const pos = computeLineDrawY( | |
| 1326 | + by, | |
| 1327 | + layoutBoxH, | |
| 1328 | + li, | |
| 1329 | + fittedLineHeight, | |
| 1330 | + visibleLines.length, | |
| 1331 | + drawFontSize, | |
| 1332 | + verticalAlign, | |
| 1333 | + ) | |
| 1334 | + textY = pos.y | |
| 1335 | + baseline = pos.baseline | |
| 1336 | + } | |
| 1337 | + drawStyledTextLine( | |
| 1338 | + ctx, | |
| 1339 | + ln, | |
| 1340 | + tx, | |
| 1341 | + textY, | |
| 1342 | + drawFontSize, | |
| 1343 | + align, | |
| 1344 | + config, | |
| 1345 | + fillColor, | |
| 1346 | + invertedBar && forPrint, | |
| 1347 | + baseline, | |
| 1348 | + ) | |
| 1349 | + }) | |
| 1350 | + if (typeof (ctx as any).setTextBaseline === 'function') { | |
| 1351 | + ;(ctx as any).setTextBaseline('alphabetic') | |
| 1352 | + } | |
| 1353 | + ctx.setTextAlign('left') | |
| 1354 | + if (typeof anyCtx.restore === 'function') anyCtx.restore() | |
| 1355 | + } | |
| 1356 | + | |
| 1357 | + if (rotationDegrees !== 0) { | |
| 1358 | + const anyCtx = ctx as any | |
| 1359 | + if (typeof anyCtx.save === 'function' && typeof anyCtx.rotate === 'function') { | |
| 1360 | + const isQuarterTurn = rotationDegrees === -90 || rotationDegrees === 90 | |
| 1361 | + anyCtx.save() | |
| 1362 | + anyCtx.translate(x + w / 2, y + h / 2) | |
| 1363 | + anyCtx.rotate((rotationDegrees * Math.PI) / 180) | |
| 1364 | + drawAt( | |
| 1365 | + isQuarterTurn ? -h / 2 : -w / 2, | |
| 1366 | + isQuarterTurn ? -w / 2 : -h / 2, | |
| 1367 | + isQuarterTurn ? h : w, | |
| 1368 | + isQuarterTurn ? w : h, | |
| 1369 | + ) | |
| 1370 | + anyCtx.restore() | |
| 1371 | + } else { | |
| 1372 | + drawAt(x, y, w, h) | |
| 1373 | + } | |
| 1374 | + } else { | |
| 1375 | + drawAt(x, y, w, h) | |
| 609 | 1376 | } |
| 610 | - const align = readTextAlign(config) | |
| 611 | - const pad = 2 | |
| 612 | - const innerW = Math.max(0, w - pad * 2) | |
| 613 | - const innerH = Math.max(fontSize, h - pad * 2) | |
| 614 | - let tx = x + pad | |
| 615 | - if (align === 'center') tx = x + w / 2 | |
| 616 | - else if (align === 'right') tx = x + w - pad | |
| 617 | - ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left') | |
| 618 | - const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15)) | |
| 619 | - const maxChars = maxCharsPerLine(innerW, fontSize) | |
| 620 | - const lines = wrapTextToWidth(text, maxChars) | |
| 621 | - const maxLines = | |
| 622 | - innerH >= fontSize ? Math.max(1, Math.floor(innerH / lineHeight)) : lines.length | |
| 623 | - const startY = y + pad + fontSize | |
| 624 | - lines.slice(0, maxLines).forEach((ln, li) => { | |
| 625 | - ctx.fillText(ln, tx, startY + li * lineHeight) | |
| 626 | - }) | |
| 627 | - ctx.setTextAlign('left') | |
| 628 | 1377 | } |
| 629 | 1378 | |
| 630 | - next() | |
| 1379 | + finishElement() | |
| 631 | 1380 | } |
| 632 | 1381 | |
| 633 | 1382 | drawRest(0) |
| 634 | - }) | |
| 1383 | + }) | |
| 1384 | + ) | |
| 635 | 1385 | } |
| 636 | 1386 | |
| 637 | 1387 | /** |
| ... | ... | @@ -683,7 +1433,7 @@ export function renderLabelPreviewCanvasToTempPathForPrint( |
| 683 | 1433 | layout: { cw: number, ch: number, outW: number, outH: number, scale: number } |
| 684 | 1434 | ): Promise<string> { |
| 685 | 1435 | const { cw, ch, outW, outH, scale } = layout |
| 686 | - return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then( | |
| 1436 | + return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale, { forPrint: true }).then( | |
| 687 | 1437 | () => |
| 688 | 1438 | new Promise<string>((resolve, reject) => { |
| 689 | 1439 | setTimeout(() => { |
| ... | ... | @@ -708,43 +1458,78 @@ export function renderLabelPreviewCanvasToTempPathForPrint( |
| 708 | 1458 | ) |
| 709 | 1459 | } |
| 710 | 1460 | |
| 1461 | +function countNonWhiteCanvasPixels (data: ArrayLike<number>, pixelCount: number): number { | |
| 1462 | + let dark = 0 | |
| 1463 | + for (let i = 0; i < pixelCount; i++) { | |
| 1464 | + const idx = i * 4 | |
| 1465 | + const red = Number(data[idx] ?? 255) | |
| 1466 | + const green = Number(data[idx + 1] ?? 255) | |
| 1467 | + const blue = Number(data[idx + 2] ?? 255) | |
| 1468 | + const alpha = Number(data[idx + 3] ?? 255) | |
| 1469 | + if (alpha > 0 && (red < 250 || green < 250 || blue < 250)) dark++ | |
| 1470 | + } | |
| 1471 | + return dark | |
| 1472 | +} | |
| 1473 | + | |
| 1474 | +function captureLabelPreviewCanvasImageData ( | |
| 1475 | + canvasId: string, | |
| 1476 | + componentInstance: any, | |
| 1477 | + outW: number, | |
| 1478 | + outH: number, | |
| 1479 | + settleMs = 150, | |
| 1480 | +): Promise<RawImageDataSource> { | |
| 1481 | + return new Promise<RawImageDataSource>((resolve, reject) => { | |
| 1482 | + setTimeout(() => { | |
| 1483 | + uni.canvasGetImageData( | |
| 1484 | + { | |
| 1485 | + canvasId, | |
| 1486 | + x: 0, | |
| 1487 | + y: 0, | |
| 1488 | + width: outW, | |
| 1489 | + height: outH, | |
| 1490 | + success: (res: any) => { | |
| 1491 | + resolve({ | |
| 1492 | + width: outW, | |
| 1493 | + height: outH, | |
| 1494 | + data: res.data, | |
| 1495 | + }) | |
| 1496 | + }, | |
| 1497 | + fail: (err: any) => { | |
| 1498 | + reject(new Error(err?.errMsg || 'canvasGetImageData for print failed')) | |
| 1499 | + }, | |
| 1500 | + }, | |
| 1501 | + componentInstance, | |
| 1502 | + ) | |
| 1503 | + }, settleMs) | |
| 1504 | + }) | |
| 1505 | +} | |
| 1506 | + | |
| 711 | 1507 | /** |
| 712 | 1508 | * 与 `shouldRasterPrintViaCanvasImageData()` 配套:内置 UPOS 等机型不走 Bitmap int[],与 Test Print 一致用 canvasGetImageData。 |
| 713 | 1509 | */ |
| 714 | -export function renderLabelPreviewCanvasImageDataForPrint( | |
| 1510 | +export async function renderLabelPreviewCanvasImageDataForPrint( | |
| 715 | 1511 | canvasId: string, |
| 716 | 1512 | componentInstance: any, |
| 717 | 1513 | template: SystemLabelTemplate, |
| 718 | 1514 | layout: { cw: number, ch: number, outW: number, outH: number, scale: number } |
| 719 | 1515 | ): Promise<RawImageDataSource> { |
| 720 | 1516 | const { cw, ch, outW, outH, scale } = layout |
| 721 | - return runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale).then( | |
| 722 | - () => | |
| 723 | - new Promise<RawImageDataSource>((resolve, reject) => { | |
| 724 | - setTimeout(() => { | |
| 725 | - uni.canvasGetImageData( | |
| 726 | - { | |
| 727 | - canvasId, | |
| 728 | - x: 0, | |
| 729 | - y: 0, | |
| 730 | - width: outW, | |
| 731 | - height: outH, | |
| 732 | - success: (res: any) => { | |
| 733 | - resolve({ | |
| 734 | - width: outW, | |
| 735 | - height: outH, | |
| 736 | - data: res.data, | |
| 737 | - }) | |
| 738 | - }, | |
| 739 | - fail: (err: any) => { | |
| 740 | - reject(new Error(err?.errMsg || 'canvasGetImageData for print failed')) | |
| 741 | - }, | |
| 742 | - }, | |
| 743 | - componentInstance | |
| 744 | - ) | |
| 745 | - }, 150) | |
| 746 | - }) | |
| 747 | - ) | |
| 1517 | + const pixelCount = Math.max(1, outW * outH) | |
| 1518 | + | |
| 1519 | + const drawAndCapture = async (settleMs: number) => { | |
| 1520 | + await runLabelPreviewCanvasDraw(canvasId, componentInstance, template, cw, ch, scale, { forPrint: true }) | |
| 1521 | + return captureLabelPreviewCanvasImageData(canvasId, componentInstance, outW, outH, settleMs) | |
| 1522 | + } | |
| 1523 | + | |
| 1524 | + let imageData = await drawAndCapture(150) | |
| 1525 | + if (countNonWhiteCanvasPixels(imageData.data, pixelCount) <= 0) { | |
| 1526 | + await settleAfterLabelCanvasResize() | |
| 1527 | + imageData = await drawAndCapture(220) | |
| 1528 | + } | |
| 1529 | + if (countNonWhiteCanvasPixels(imageData.data, pixelCount) <= 0) { | |
| 1530 | + throw new Error(`CANVAS_RASTER_EMPTY:size=${outW}x${outH}`) | |
| 1531 | + } | |
| 1532 | + return imageData | |
| 748 | 1533 | } |
| 749 | 1534 | |
| 750 | 1535 | /** |
| ... | ... | @@ -767,15 +1552,8 @@ export function getLabelPrintRasterLayout( |
| 767 | 1552 | template: SystemLabelTemplate, |
| 768 | 1553 | maxWidthDots: number, |
| 769 | 1554 | printDpi = 203, |
| 770 | - opts?: { contentHeightPx?: number }, | |
| 771 | 1555 | ): { cw: number, ch: number, outW: number, outH: number, scale: number } { |
| 772 | - const unit = template.unit || 'inch' | |
| 773 | - const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) | |
| 774 | - const fullH = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 775 | - const ch = | |
| 776 | - opts?.contentHeightPx != null && opts.contentHeightPx > 0 | |
| 777 | - ? Math.max(40, Math.round(opts.contentHeightPx)) | |
| 778 | - : fullH | |
| 1556 | + const { cw, ch } = resolveLabelDesignCanvasPx(template) | |
| 779 | 1557 | const designDpi = 96 |
| 780 | 1558 | const idealW = Math.round(cw * (printDpi / designDpi)) |
| 781 | 1559 | const cap = Math.max(8, Math.round(maxWidthDots || 576)) |
| ... | ... | @@ -791,9 +1569,7 @@ export function getPreviewCanvasCssSize(template: SystemLabelTemplate, maxDispla |
| 791 | 1569 | width: number |
| 792 | 1570 | height: number |
| 793 | 1571 | } { |
| 794 | - const unit = template.unit || 'inch' | |
| 795 | - const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) | |
| 796 | - const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 1572 | + const { cw, ch } = resolveLabelDesignCanvasPx(template) | |
| 797 | 1573 | const scale = Math.min(1, maxDisplayWidthPx / cw) |
| 798 | 1574 | return { |
| 799 | 1575 | width: Math.max(1, Math.round(cw * scale)), | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/nutritionFactsLayout.ts
0 → 100644
| 1 | +/** | |
| 2 | + * US Nutrition Facts 面板布局(与 Web src/lib/nutritionFactsLayout.ts 保持一致) | |
| 3 | + */ | |
| 4 | +export type NutritionDivider = 'none' | 'thin' | 'double' | |
| 5 | + | |
| 6 | +export type NutritionLayoutRowDef = { | |
| 7 | + key: string | |
| 8 | + label: string | |
| 9 | + defaultUnit: string | |
| 10 | + labelBold?: boolean | |
| 11 | + indent?: boolean | |
| 12 | + dividerAfter?: NutritionDivider | |
| 13 | +} | |
| 14 | + | |
| 15 | +export const NUTRITION_FACTS_LAYOUT_ROWS: readonly NutritionLayoutRowDef[] = [ | |
| 16 | + { key: 'fat', label: 'Total Fat', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' }, | |
| 17 | + { key: 'transFat', label: 'Trans Fat', defaultUnit: 'g', dividerAfter: 'thin' }, | |
| 18 | + { key: 'cholesterol', label: 'Cholesterol', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' }, | |
| 19 | + { key: 'sodium', label: 'Sodium', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' }, | |
| 20 | + { key: 'carbs', label: 'Total Carbo.', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' }, | |
| 21 | + { key: 'totalSugar', label: 'Sugars', defaultUnit: 'g', dividerAfter: 'thin' }, | |
| 22 | + { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g', dividerAfter: 'thin' }, | |
| 23 | + { key: 'protein', label: 'Protein', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' }, | |
| 24 | + { key: 'calcium', label: 'Calcium', defaultUnit: 'mg', dividerAfter: 'thin' }, | |
| 25 | + { key: 'potassium', label: 'Potassium', defaultUnit: 'mg', dividerAfter: 'thin' }, | |
| 26 | + { key: 'vitaminA', label: 'Vitamin A', defaultUnit: 'mg', dividerAfter: 'thin' }, | |
| 27 | + { key: 'vitaminD', label: 'Vitamin D', defaultUnit: 'mg', dividerAfter: 'thin' }, | |
| 28 | + { key: 'iron', label: 'Iron', defaultUnit: 'mg', dividerAfter: 'none' }, | |
| 29 | +] as const | |
| 30 | + | |
| 31 | +export const DEFAULT_NUTRITION_FOOTER_NOTE = | |
| 32 | + '* Percent Daily Values are based on a 2000 calorie diet' | |
| 33 | + | |
| 34 | +/** 与 Web src/lib/nutritionFactsLayout.ts 一致 */ | |
| 35 | +export const NUTRITION_AMOUNT_COL_WIDTH = 64 | |
| 36 | +export const NUTRITION_PCT_COL_WIDTH = 44 | |
| 37 | +export const NUTRITION_BODY_FONT_SIZE = 12 | |
| 38 | + | |
| 39 | +export type NutritionFactsRowView = { | |
| 40 | + key: string | |
| 41 | + label: string | |
| 42 | + amountText: string | |
| 43 | + dailyValueText: string | |
| 44 | + labelBold: boolean | |
| 45 | + indent: boolean | |
| 46 | + dividerAfter: NutritionDivider | |
| 47 | +} | |
| 48 | + | |
| 49 | +export type NutritionFactsViewModel = { | |
| 50 | + titleFontSize: number | |
| 51 | + servingsLabel: string | |
| 52 | + servingsValue: string | |
| 53 | + servingSizeLabel: string | |
| 54 | + servingSizeValue: string | |
| 55 | + caloriesLabel: string | |
| 56 | + caloriesValue: string | |
| 57 | + caloriesAmountText: string | |
| 58 | + rows: NutritionFactsRowView[] | |
| 59 | + footerNote: string | |
| 60 | + ingredientsText: string | |
| 61 | +} | |
| 62 | + | |
| 63 | +function cfgStr(cfg: Record<string, unknown>, keys: string[], fallback = ''): string { | |
| 64 | + for (const k of keys) { | |
| 65 | + const v = cfg[k] | |
| 66 | + if (v != null && String(v).trim() !== '') return String(v).trim() | |
| 67 | + } | |
| 68 | + return fallback | |
| 69 | +} | |
| 70 | + | |
| 71 | +function cfgBool(cfg: Record<string, unknown>, keys: string[]): boolean { | |
| 72 | + for (const k of keys) { | |
| 73 | + const v = cfg[k] | |
| 74 | + if (v === true || v === 'true' || v === 1 || v === '1') return true | |
| 75 | + if (v === false || v === 'false' || v === 0 || v === '0') return false | |
| 76 | + } | |
| 77 | + return false | |
| 78 | +} | |
| 79 | + | |
| 80 | +function fixedRows(cfg: Record<string, unknown>): Record<string, unknown>[] { | |
| 81 | + return Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : [] | |
| 82 | +} | |
| 83 | + | |
| 84 | +function rowFromFixed(cfg: Record<string, unknown>, key: string): Record<string, unknown> | undefined { | |
| 85 | + return fixedRows(cfg).find((r) => String(r.key ?? '').trim() === key) | |
| 86 | +} | |
| 87 | + | |
| 88 | +export function readNutritionLessThan(cfg: Record<string, unknown>, key: string): boolean { | |
| 89 | + const row = rowFromFixed(cfg, key) | |
| 90 | + if (row && row.lessThan != null) return cfgBool({ lessThan: row.lessThan }, ['lessThan']) | |
| 91 | + return cfgBool(cfg, [`${key}LessThan`, `${key}UseLessThan`]) | |
| 92 | +} | |
| 93 | + | |
| 94 | +export function nutritionFixedField( | |
| 95 | + cfg: Record<string, unknown>, | |
| 96 | + key: string, | |
| 97 | + field: 'value' | 'unit' | 'dailyValuePercent', | |
| 98 | +): string { | |
| 99 | + const row = rowFromFixed(cfg, key) | |
| 100 | + if (field === 'dailyValuePercent') { | |
| 101 | + const fromRow = row?.dailyValuePercent ?? row?.percent ?? row?.Percent | |
| 102 | + if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim() | |
| 103 | + return cfgStr(cfg, [`${key}Percent`, `${key}DailyValue`, `${key}DailyValuePercent`], '') | |
| 104 | + } | |
| 105 | + if (field === 'unit') { | |
| 106 | + const fromRow = row?.unit | |
| 107 | + if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim() | |
| 108 | + const def = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key) | |
| 109 | + return cfgStr(cfg, [`${key}Unit`], def?.defaultUnit ?? '') | |
| 110 | + } | |
| 111 | + const fromRow = row?.value | |
| 112 | + if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim() | |
| 113 | + return cfgStr(cfg, [key, key.charAt(0).toUpperCase() + key.slice(1)], '') | |
| 114 | +} | |
| 115 | + | |
| 116 | +export function formatNutritionAmount( | |
| 117 | + value: string, | |
| 118 | + _unit?: string, | |
| 119 | + lessThan?: boolean, | |
| 120 | +): string { | |
| 121 | + const v = String(value ?? '').trim() | |
| 122 | + if (!v) return '' | |
| 123 | + const prefix = lessThan ? '<' : '' | |
| 124 | + return `${prefix}${v}` | |
| 125 | +} | |
| 126 | + | |
| 127 | +export function formatNutritionDailyValue(raw: string): string { | |
| 128 | + const v = String(raw ?? '').trim() | |
| 129 | + if (!v) return '' | |
| 130 | + return v.endsWith('%') ? v : `${v}%` | |
| 131 | +} | |
| 132 | + | |
| 133 | +function nutritionExtraRows(cfg: Record<string, unknown>): Array<{ id: string; name: string; value: string; unit: string }> { | |
| 134 | + const raw = cfg.extraNutrients | |
| 135 | + if (!Array.isArray(raw)) return [] | |
| 136 | + return raw.map((item, idx) => { | |
| 137 | + const row = item as Record<string, unknown> | |
| 138 | + return { | |
| 139 | + id: String(row.id ?? `extra-${idx}`), | |
| 140 | + name: String(row.name ?? ''), | |
| 141 | + value: String(row.value ?? ''), | |
| 142 | + unit: String(row.unit ?? ''), | |
| 143 | + } | |
| 144 | + }) | |
| 145 | +} | |
| 146 | + | |
| 147 | +export function buildNutritionFactsViewModel(cfg: Record<string, unknown>): NutritionFactsViewModel { | |
| 148 | + const titleFontSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16 | |
| 149 | + const servingsValue = cfgStr(cfg, ['servings', 'servingsPerContainer', 'ServingsPerContainer']) | |
| 150 | + const servingSizeValue = cfgStr(cfg, ['servingSize', 'ServingSize']) | |
| 151 | + const caloriesRaw = nutritionFixedField(cfg, 'calories', 'value') || cfgStr(cfg, ['calories', 'Calories']) | |
| 152 | + const caloriesLessThan = readNutritionLessThan(cfg, 'calories') | |
| 153 | + const layoutByKey = new Map(NUTRITION_FACTS_LAYOUT_ROWS.map((r) => [r.key, r])) | |
| 154 | + const rows: NutritionFactsRowView[] = [] | |
| 155 | + const seen = new Set<string>() | |
| 156 | + | |
| 157 | + for (const def of NUTRITION_FACTS_LAYOUT_ROWS) { | |
| 158 | + seen.add(def.key) | |
| 159 | + const value = nutritionFixedField(cfg, def.key, 'value') | |
| 160 | + const lessThan = readNutritionLessThan(cfg, def.key) | |
| 161 | + const pct = nutritionFixedField(cfg, def.key, 'dailyValuePercent') | |
| 162 | + rows.push({ | |
| 163 | + key: def.key, | |
| 164 | + label: String(rowFromFixed(cfg, def.key)?.label ?? def.label), | |
| 165 | + amountText: formatNutritionAmount(value, '', lessThan), | |
| 166 | + dailyValueText: formatNutritionDailyValue(pct), | |
| 167 | + labelBold: def.labelBold ?? false, | |
| 168 | + indent: def.indent ?? false, | |
| 169 | + dividerAfter: def.dividerAfter ?? 'none', | |
| 170 | + }) | |
| 171 | + } | |
| 172 | + | |
| 173 | + for (const ex of nutritionExtraRows(cfg)) { | |
| 174 | + const key = `extra:${ex.id}` | |
| 175 | + if (seen.has(key)) continue | |
| 176 | + const lessThan = cfgBool(cfg, [`extra:${ex.id}:lessThan`]) | |
| 177 | + rows.push({ | |
| 178 | + key, | |
| 179 | + label: ex.name.trim() || 'Other', | |
| 180 | + amountText: formatNutritionAmount(ex.value, '', lessThan), | |
| 181 | + dailyValueText: formatNutritionDailyValue( | |
| 182 | + cfgStr(cfg, [`extra:${ex.id}:percent`, `extra:${ex.id}:dailyValuePercent`]), | |
| 183 | + ), | |
| 184 | + labelBold: false, | |
| 185 | + indent: false, | |
| 186 | + dividerAfter: 'thin', | |
| 187 | + }) | |
| 188 | + } | |
| 189 | + | |
| 190 | + for (const fr of fixedRows(cfg)) { | |
| 191 | + const key = String(fr.key ?? '').trim() | |
| 192 | + if (!key || seen.has(key)) continue | |
| 193 | + const def = layoutByKey.get(key) | |
| 194 | + const value = String(fr.value ?? '').trim() | |
| 195 | + const lessThan = cfgBool({ lessThan: fr.lessThan }, ['lessThan']) | |
| 196 | + rows.push({ | |
| 197 | + key, | |
| 198 | + label: String(fr.label ?? def?.label ?? key), | |
| 199 | + amountText: formatNutritionAmount(value, '', lessThan), | |
| 200 | + dailyValueText: formatNutritionDailyValue(String(fr.dailyValuePercent ?? fr.percent ?? '')), | |
| 201 | + labelBold: def?.labelBold ?? false, | |
| 202 | + indent: def?.indent ?? false, | |
| 203 | + dividerAfter: def?.dividerAfter ?? 'thin', | |
| 204 | + }) | |
| 205 | + } | |
| 206 | + | |
| 207 | + return { | |
| 208 | + titleFontSize, | |
| 209 | + servingsLabel: cfgStr(cfg, ['servingsLabel'], 'Servings'), | |
| 210 | + servingsValue, | |
| 211 | + servingSizeLabel: cfgStr(cfg, ['servingSizeLabel'], 'Serve size'), | |
| 212 | + servingSizeValue, | |
| 213 | + caloriesLabel: cfgStr(cfg, ['caloriesLabel'], 'Calories'), | |
| 214 | + caloriesValue: caloriesRaw, | |
| 215 | + caloriesAmountText: formatNutritionAmount(caloriesRaw, '', caloriesLessThan), | |
| 216 | + rows, | |
| 217 | + footerNote: cfgStr(cfg, ['nutritionFooterNote', 'footerNote'], DEFAULT_NUTRITION_FOOTER_NOTE), | |
| 218 | + ingredientsText: cfgStr(cfg, ['ingredientsText', 'ingredients', 'IngredientsText']), | |
| 219 | + } | |
| 220 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/offlineSyncManager.ts
| ... | ... | @@ -6,6 +6,7 @@ import { |
| 6 | 6 | fetchUsAppPrintLogList, |
| 7 | 7 | prefetchUsAppLabelPreviewsForLocation, |
| 8 | 8 | } from '../services/usAppLabeling' |
| 9 | +import type { UsAppLabelCategoryTreeNodeDto } from '../types/usAppLabeling' | |
| 9 | 10 | import { fetchGlobalSupportContact } from '../services/locationSupport' |
| 10 | 11 | import { |
| 11 | 12 | getAccessToken, |
| ... | ... | @@ -84,6 +85,19 @@ function setLastSyncExecutionDetail(detail: OfflineSyncExecutionDetail): void { |
| 84 | 85 | uni.setStorageSync(KEY_LAST_SYNC_EXECUTION, JSON.stringify(detail)) |
| 85 | 86 | } |
| 86 | 87 | |
| 88 | +/** 后台预拉标签 preview(不阻塞登录/同步 UI) */ | |
| 89 | +async function prefetchLabelPreviewsInBackground( | |
| 90 | + tasks: Array<{ locId: string; tree: UsAppLabelCategoryTreeNodeDto[] }>, | |
| 91 | +): Promise<void> { | |
| 92 | + for (const { locId, tree } of tasks) { | |
| 93 | + try { | |
| 94 | + await prefetchUsAppLabelPreviewsForLocation(locId, tree) | |
| 95 | + } catch { | |
| 96 | + // 单店失败不阻断其余门店 | |
| 97 | + } | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 87 | 101 | export async function performInitialOfflineSync(): Promise<void> { |
| 88 | 102 | await initOfflineSqlite() |
| 89 | 103 | const online = await isNetworkOnline() |
| ... | ... | @@ -102,6 +116,7 @@ export async function performInitialOfflineSync(): Promise<void> { |
| 102 | 116 | [currentStoreId, ...locations.map((x) => x.id).filter(Boolean)].filter((x) => !!String(x).trim()) |
| 103 | 117 | ) |
| 104 | 118 | ) |
| 119 | + const previewPrefetchTasks: Array<{ locId: string; tree: UsAppLabelCategoryTreeNodeDto[] }> = [] | |
| 105 | 120 | for (const locId of targetLocationIds) { |
| 106 | 121 | // 每个门店一组缓存,确保离线切店后仍有可读数据 |
| 107 | 122 | const [, treeResult] = await Promise.allSettled([ |
| ... | ... | @@ -110,7 +125,7 @@ export async function performInitialOfflineSync(): Promise<void> { |
| 110 | 125 | fetchUsAppPrintLogList({ locationId: locId, skipCount: 1, maxResultCount: 20 }), |
| 111 | 126 | ]) |
| 112 | 127 | if (treeResult.status === 'fulfilled' && Array.isArray(treeResult.value)) { |
| 113 | - await prefetchUsAppLabelPreviewsForLocation(locId, treeResult.value) | |
| 128 | + previewPrefetchTasks.push({ locId, tree: treeResult.value }) | |
| 114 | 129 | } |
| 115 | 130 | } |
| 116 | 131 | await Promise.allSettled([ |
| ... | ... | @@ -133,6 +148,11 @@ export async function performInitialOfflineSync(): Promise<void> { |
| 133 | 148 | } |
| 134 | 149 | |
| 135 | 150 | setLastSyncAt(Date.now()) |
| 151 | + | |
| 152 | + // 预览接口仅用于离线缓存,逐条 POST 成本高;放后台预拉,避免阻塞登录/手动同步 | |
| 153 | + if (previewPrefetchTasks.length > 0) { | |
| 154 | + void prefetchLabelPreviewsInBackground(previewPrefetchTasks) | |
| 155 | + } | |
| 136 | 156 | } |
| 137 | 157 | |
| 138 | 158 | export async function flushPendingMutations(): Promise<number> { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/bleWriteModeRules.ts
| 1 | 1 | /** |
| 2 | - * 部分标签机 BLE 串口:GATT 同时声明 write / writeNoResponse,但数据口实际只接受 Write Command(无响应)。 | |
| 3 | - * 用默认「带响应写」时常见首包过、第二包起 writeBLECharacteristicValue:fail property not support (10007)。 | |
| 4 | - * printerManager 在「同时声明两种属性」时对本白名单 UUID 优先选 writeNoResponse;若系统只暴露 write,则绝不强行 Command 写(否则 10007)。 | |
| 5 | - * sendViaBle 在已选无响应写仍 10007 时,对白名单禁止翻到带响应写(避免佳博长任务第二包必挂)。 | |
| 2 | + * 佳博 GP-D320FX 等 Nordic UART 数据口:实机必须 writeNoResponse 才能稳定长任务下发。 | |
| 3 | + * 安卓 GATT 常只声明 write,若按 GATT 走带响应写,可能首包或跑十几包后 10007 property not support。 | |
| 4 | + * 白名单 UUID 连接/打印时一律优先 writeNoResponse;首包仍 10007 时才允许试一次带响应写。 | |
| 5 | + * 已用 writeNoResponse 跑通后禁止切回带响应写。 | |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | export function normalizeBleUuid (uuid: string): string { |
| ... | ... | @@ -11,16 +11,25 @@ export function normalizeBleUuid (uuid: string): string { |
| 11 | 11 | |
| 12 | 12 | /** serviceUuid + characteristicUuid(无横线小写) */ |
| 13 | 13 | const FORCE_WRITE_NO_RESPONSE: Array<{ service: string; characteristic: string }> = [ |
| 14 | - /** 佳博 GP-D320FX 等常见 Nordic UART 风格串口(与你机子日志一致) */ | |
| 14 | + /** 佳博 GP-D320FX 等 Silicon Labs / 旧 Nordic UART */ | |
| 15 | 15 | { |
| 16 | 16 | service: '49535343fe7d4ae58fa99fafd205e455', |
| 17 | 17 | characteristic: '49535343884143f4a8d4ecbe34729bb3', |
| 18 | 18 | }, |
| 19 | + /** 标准 Nordic UART (NUS) */ | |
| 20 | + { | |
| 21 | + service: '6e400001b5a3f393e0a9e50e24dcca9e', | |
| 22 | + characteristic: '6e400002b5a3f393e0a9e50e24dcca9e', | |
| 23 | + }, | |
| 19 | 24 | ] |
| 20 | 25 | |
| 26 | +const NORDIC_UART_SERVICE_IDS = new Set( | |
| 27 | + FORCE_WRITE_NO_RESPONSE.map((p) => p.service), | |
| 28 | +) | |
| 29 | + | |
| 21 | 30 | export function blePairRequiresWriteNoResponse ( |
| 22 | 31 | serviceId: string, |
| 23 | - characteristicId: string | |
| 32 | + characteristicId: string, | |
| 24 | 33 | ): boolean { |
| 25 | 34 | const s = normalizeBleUuid(serviceId) |
| 26 | 35 | const c = normalizeBleUuid(characteristicId) |
| ... | ... | @@ -29,6 +38,5 @@ export function blePairRequiresWriteNoResponse ( |
| 29 | 38 | |
| 30 | 39 | /** 是否为本仓库维护的 Nordic UART 风格串口服务(需写前开 notify、分包间留间隔等) */ |
| 31 | 40 | export function isNordicUartStyleBleService (serviceId: string): boolean { |
| 32 | - const s = normalizeBleUuid(serviceId) | |
| 33 | - return FORCE_WRITE_NO_RESPONSE.some((p) => p.service === s) | |
| 41 | + return NORDIC_UART_SERVICE_IDS.has(normalizeBleUuid(serviceId)) | |
| 34 | 42 | } | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/bluetoothPrinterAllowlist.ts
| 1 | -/** 扫描/配对列表仅展示以下蓝牙名称(大小写不敏感,须完整匹配) */ | |
| 1 | +/** 扫描/配对列表仅展示以下蓝牙名称(大小写不敏感;支持完整匹配或关键字包含) */ | |
| 2 | 2 | export const ALLOWED_BLUETOOTH_PRINTER_NAMES = [ |
| 3 | 3 | 'GP-D320FX-spp_A7FO', |
| 4 | 4 | 'Virtual BT Printer', |
| ... | ... | @@ -8,6 +8,13 @@ const ALLOWED_BLUETOOTH_PRINTER_NAME_SET = new Set( |
| 8 | 8 | ALLOWED_BLUETOOTH_PRINTER_NAMES.map((name) => name.toLowerCase()), |
| 9 | 9 | ) |
| 10 | 10 | |
| 11 | +/** 名称包含以下片段也视为允许(兼容系统配对名略有差异) */ | |
| 12 | +const ALLOWED_BLUETOOTH_PRINTER_NAME_FRAGMENTS = [ | |
| 13 | + 'virtual bt', | |
| 14 | + 'gp-d320fx', | |
| 15 | + 'd320fx', | |
| 16 | +] as const | |
| 17 | + | |
| 11 | 18 | export function normalizeBluetoothPrinterName (name: string | undefined | null): string { |
| 12 | 19 | return String(name ?? '').trim() |
| 13 | 20 | } |
| ... | ... | @@ -16,7 +23,9 @@ export function normalizeBluetoothPrinterName (name: string | undefined | null): |
| 16 | 23 | export function isAllowedBluetoothPrinterName (name: string | undefined | null): boolean { |
| 17 | 24 | const normalized = normalizeBluetoothPrinterName(name) |
| 18 | 25 | if (!normalized) return false |
| 19 | - return ALLOWED_BLUETOOTH_PRINTER_NAME_SET.has(normalized.toLowerCase()) | |
| 26 | + const lower = normalized.toLowerCase() | |
| 27 | + if (ALLOWED_BLUETOOTH_PRINTER_NAME_SET.has(lower)) return true | |
| 28 | + return ALLOWED_BLUETOOTH_PRINTER_NAME_FRAGMENTS.some((frag) => lower.includes(frag)) | |
| 20 | 29 | } |
| 21 | 30 | |
| 22 | 31 | /** 一体机虚拟蓝牙名(走整页光栅与预览一致,不走 native printTemplate) */ | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/bluetoothTool.js
| ... | ... | @@ -80,6 +80,24 @@ function isConnectionStateConnected (state) { |
| 80 | 80 | return String(state || '').trim().toLowerCase() === 'connected' |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | +function getClassicBondState (device) { | |
| 84 | + // #ifdef APP-PLUS | |
| 85 | + try { | |
| 86 | + const state = invoke(device, 'getBondState') | |
| 87 | + return typeof state === 'number' ? state : Number(state) | |
| 88 | + } catch (_) { | |
| 89 | + return null | |
| 90 | + } | |
| 91 | + // #endif | |
| 92 | + return null | |
| 93 | +} | |
| 94 | + | |
| 95 | +function isClassicBonded (device) { | |
| 96 | + const state = getClassicBondState(device) | |
| 97 | + if (state == null || Number.isNaN(state)) return true | |
| 98 | + return state === 12 | |
| 99 | +} | |
| 100 | + | |
| 83 | 101 | function runOnUiThread (fn) { |
| 84 | 102 | // #ifdef APP-PLUS |
| 85 | 103 | try { |
| ... | ... | @@ -380,8 +398,9 @@ var blueToothTool = { |
| 380 | 398 | callback && callback(false) |
| 381 | 399 | return false |
| 382 | 400 | } |
| 401 | + let device = null | |
| 383 | 402 | try { |
| 384 | - let device = invoke(btAdapter, 'getRemoteDevice', address) | |
| 403 | + device = invoke(btAdapter, 'getRemoteDevice', address) | |
| 385 | 404 | const candidates = createSocketCandidates(device) |
| 386 | 405 | let socket = null |
| 387 | 406 | let lastError = null |
| ... | ... | @@ -415,6 +434,9 @@ var blueToothTool = { |
| 415 | 434 | |
| 416 | 435 | try { |
| 417 | 436 | invoke(btSocket, 'connect') |
| 437 | + if (!isClassicBonded(device)) { | |
| 438 | + throw new Error('Bluetooth PIN or pairing key is incorrect') | |
| 439 | + } | |
| 418 | 440 | const streamReady = this.readData() |
| 419 | 441 | if (!streamReady) { |
| 420 | 442 | throw new Error(this.state.lastError || 'Bluetooth output stream not ready') | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/imageRaster.ts
| ... | ... | @@ -2,6 +2,47 @@ import type { MonochromeImageData, PrintImageOptions, PrinterDriver, RawImageDat |
| 2 | 2 | import { printRunDiag } from './printRunDiagnostics' |
| 3 | 3 | |
| 4 | 4 | const DEFAULT_IMAGE_THRESHOLD = 180 |
| 5 | +/** 黑底白字标签:提高阈值保留更多白字像素,减轻抗锯齿边缘被二值化成黑 */ | |
| 6 | +export const INVERTED_TEXT_IMAGE_THRESHOLD = 205 | |
| 7 | + | |
| 8 | +/** | |
| 9 | + * 仅对「深陷黑块内的白字」做 1px 白膨胀,避免热敏渗墨吞字;不影响普通黑字白底。 | |
| 10 | + */ | |
| 11 | +export function sharpenInvertedTextInMonochromeRaster ( | |
| 12 | + image: MonochromeImageData, | |
| 13 | +): MonochromeImageData { | |
| 14 | + const { width, height, pixels } = image | |
| 15 | + if (!width || !height || !pixels?.length) return image | |
| 16 | + const next = pixels.slice() | |
| 17 | + | |
| 18 | + for (let y = 0; y < height; y++) { | |
| 19 | + for (let x = 0; x < width; x++) { | |
| 20 | + const idx = y * width + x | |
| 21 | + if (pixels[idx] !== 0) continue | |
| 22 | + | |
| 23 | + let blackNeighbors = 0 | |
| 24 | + for (let dy = -1; dy <= 1; dy++) { | |
| 25 | + for (let dx = -1; dx <= 1; dx++) { | |
| 26 | + if (dx === 0 && dy === 0) continue | |
| 27 | + const nx = x + dx | |
| 28 | + const ny = y + dy | |
| 29 | + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue | |
| 30 | + if (pixels[ny * width + nx]) blackNeighbors++ | |
| 31 | + } | |
| 32 | + } | |
| 33 | + if (blackNeighbors < 5) continue | |
| 34 | + | |
| 35 | + for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) { | |
| 36 | + const nx = x + dx | |
| 37 | + const ny = y + dy | |
| 38 | + if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue | |
| 39 | + if (pixels[ny * width + nx]) next[ny * width + nx] = 0 | |
| 40 | + } | |
| 41 | + } | |
| 42 | + } | |
| 43 | + | |
| 44 | + return { width, height, pixels: next } | |
| 45 | +} | |
| 5 | 46 | |
| 6 | 47 | /** 去掉位图底部全白行(内置 ESC 光栅按图像高度走纸,否则会拖很长空白) */ |
| 7 | 48 | export function trimMonochromeImageBottomWhitespace ( | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
| 1 | -import { blePairRequiresWriteNoResponse } from '../bleWriteModeRules' | |
| 2 | 1 | import { |
| 3 | 2 | clearPrinter, |
| 3 | + discoverBleWriteCharacteristic, | |
| 4 | 4 | ensureBleUartNotifyIfNeeded, |
| 5 | 5 | getBluetoothConnection, |
| 6 | 6 | getCurrentPrinterDriverKey, |
| 7 | 7 | getPrinterType, |
| 8 | 8 | getStoredUposPrintOptions, |
| 9 | 9 | isNativeBaseClassicBluetoothTransport, |
| 10 | + isVirtualBtBluetoothConnection, | |
| 10 | 11 | sendToPrinter, |
| 11 | 12 | setBluetoothConnection, |
| 12 | 13 | setBuiltinPrinter, |
| 13 | 14 | } from '../printerConnection' |
| 14 | 15 | // @ts-ignore - js bridge module (app-plus only) |
| 15 | 16 | import classicBluetooth from '../bluetoothTool.js' |
| 16 | -import { rasterizeImageData, rasterizeImageForPrinter, trimMonochromeImageBottomWhitespace } from '../imageRaster' | |
| 17 | +import { rasterizeImageData, rasterizeImageForPrinter, trimMonochromeImageBottomWhitespace, sharpenInvertedTextInMonochromeRaster, INVERTED_TEXT_IMAGE_THRESHOLD } from '../imageRaster' | |
| 17 | 18 | import { buildEscPosImageData, buildEscPosTemplateData } from '../protocols/escPosBuilder' |
| 18 | 19 | import { buildTscImageData, buildTscTemplateData } from '../protocols/tscProtocol' |
| 19 | 20 | import type { LabelPrintJobPayload } from '../../labelPreview/buildLabelPrintPayload' |
| ... | ... | @@ -32,12 +33,13 @@ import { |
| 32 | 33 | } from '../../labelPreview/renderLabelPreviewCanvas' |
| 33 | 34 | import { |
| 34 | 35 | ensureTemplateHeightCoversElements, |
| 35 | - templateContentHeightPx, | |
| 36 | - templateSizeToMillimeters, | |
| 36 | + rasterDotsToMillimeters, | |
| 37 | 37 | } from '../templatePhysicalMm' |
| 38 | 38 | import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl' |
| 39 | 39 | import { printRunDiag } from '../printRunDiagnostics' |
| 40 | 40 | import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' |
| 41 | +import { templateRequiresCanvasStyleFidelity } from '../nativeTemplateElementSupport' | |
| 42 | +import { templateHasInvertedTextElements } from '../../invertColorsConfig' | |
| 41 | 43 | import { hydrateSystemTemplateImagesForPrint } from '../hydrateTemplateImagesForPrint' |
| 42 | 44 | import { TEST_PRINT_SYSTEM_TEMPLATE, TEST_PRINT_TEMPLATE_DATA } from '../templates/testPrintTemplate' |
| 43 | 45 | import { describePrinterCandidate, getPrinterDriverByKey, resolvePrinterDriver } from './driverRegistry' |
| ... | ... | @@ -97,14 +99,6 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 97 | 99 | } |
| 98 | 100 | classicBluetooth.connDevice(device.deviceId, (ok: boolean) => { |
| 99 | 101 | if (ok) { |
| 100 | - setBluetoothConnection({ | |
| 101 | - deviceId: device.deviceId, | |
| 102 | - deviceName: device.name || 'Bluetooth Printer', | |
| 103 | - deviceType: 'classic', | |
| 104 | - transportMode: 'generic', | |
| 105 | - driverKey: driver.key, | |
| 106 | - mtu: driver.preferredBleMtu || 20, | |
| 107 | - }) | |
| 108 | 102 | /** |
| 109 | 103 | * connDevice 回调 ok 可能早于 socket/outputStream 就绪; |
| 110 | 104 | * 若立刻测试打印,会出现 state=idle、outputReady=false。 |
| ... | ... | @@ -116,10 +110,19 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 116 | 110 | : null |
| 117 | 111 | const ready = !!st?.outputReady && (!!st?.socketConnected || String(st?.connectionState || '').toLowerCase() === 'connected') |
| 118 | 112 | if (ready) { |
| 113 | + setBluetoothConnection({ | |
| 114 | + deviceId: device.deviceId, | |
| 115 | + deviceName: device.name || 'Bluetooth Printer', | |
| 116 | + deviceType: 'classic', | |
| 117 | + transportMode: 'generic', | |
| 118 | + driverKey: driver.key, | |
| 119 | + mtu: driver.preferredBleMtu || 20, | |
| 120 | + }) | |
| 119 | 121 | resolve() |
| 120 | 122 | return |
| 121 | 123 | } |
| 122 | 124 | if (Date.now() - start > 2500) { |
| 125 | + clearPrinter() | |
| 123 | 126 | reject(new Error('Classic Bluetooth connection is not ready')) |
| 124 | 127 | return |
| 125 | 128 | } |
| ... | ... | @@ -131,9 +134,11 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 131 | 134 | const message = typeof classicBluetooth.getLastError === 'function' |
| 132 | 135 | ? classicBluetooth.getLastError() |
| 133 | 136 | : '' |
| 137 | + clearPrinter() | |
| 134 | 138 | reject(new Error(message || 'Classic Bluetooth connection failed.')) |
| 135 | 139 | }) |
| 136 | 140 | } catch (error: any) { |
| 141 | + clearPrinter() | |
| 137 | 142 | reject(error instanceof Error ? error : new Error(String(error || 'Classic Bluetooth connection failed.'))) |
| 138 | 143 | } |
| 139 | 144 | } |
| ... | ... | @@ -173,93 +178,6 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 173 | 178 | }) |
| 174 | 179 | } |
| 175 | 180 | |
| 176 | -/** | |
| 177 | - * 优先带响应 write(与 uni 默认写入方式一致);仅当没有 write 再用 writeNoResponse(需在下发时传 writeType)。 | |
| 178 | - * 若系统 GATT 只声明 write、未声明 writeNoResponse,却强行 writeNoResponse,会报 property not support (10007)。 | |
| 179 | - */ | |
| 180 | -function hasBleWriteProperty (item: any): boolean { | |
| 181 | - const w = item.properties?.write | |
| 182 | - return w === true || w === 'true' | |
| 183 | -} | |
| 184 | - | |
| 185 | -function hasBleWriteNoResponseProperty (item: any): boolean { | |
| 186 | - const p = item.properties || {} | |
| 187 | - return ( | |
| 188 | - p.writeNoResponse === true || | |
| 189 | - p.writeNoResponse === 'true' || | |
| 190 | - p.writeWithoutResponse === true || | |
| 191 | - p.writeWithoutResponse === 'true' | |
| 192 | - ) | |
| 193 | -} | |
| 194 | - | |
| 195 | -/** | |
| 196 | - * 无 writeNoResponse 属性则绝不走 Command 写。 | |
| 197 | - * Nordic 白名单在「同时声明 write + writeNoResponse」时优先无响应写(佳博等实测);否则有 write 时优先带响应。 | |
| 198 | - */ | |
| 199 | -function pickBleWriteUsesNoResponse (serviceId: string, item: any): boolean { | |
| 200 | - const hw = hasBleWriteProperty(item) | |
| 201 | - const hn = hasBleWriteNoResponseProperty(item) | |
| 202 | - if (!hn) return false | |
| 203 | - if (!hw) return true | |
| 204 | - return blePairRequiresWriteNoResponse(serviceId, String(item.uuid || '')) | |
| 205 | -} | |
| 206 | - | |
| 207 | -function findBleWriteCharacteristic (deviceId: string): Promise<{ | |
| 208 | - serviceId: string | |
| 209 | - characteristicId: string | |
| 210 | - bleWriteUsesNoResponse: boolean | |
| 211 | -} | null> { | |
| 212 | - return new Promise((resolve) => { | |
| 213 | - uni.getBLEDeviceServices({ | |
| 214 | - deviceId, | |
| 215 | - success: (serviceRes) => { | |
| 216 | - const services = serviceRes.services || [] | |
| 217 | - const next = (index: number) => { | |
| 218 | - if (index >= services.length) { | |
| 219 | - resolve(null) | |
| 220 | - return | |
| 221 | - } | |
| 222 | - const serviceId = services[index].uuid | |
| 223 | - uni.getBLEDeviceCharacteristics({ | |
| 224 | - deviceId, | |
| 225 | - serviceId, | |
| 226 | - success: (charRes) => { | |
| 227 | - const chars = charRes.characteristics || [] | |
| 228 | - const writable = (item: any) => hasBleWriteProperty(item) || hasBleWriteNoResponseProperty(item) | |
| 229 | - for (const item of chars) { | |
| 230 | - const cid = String(item.uuid || '') | |
| 231 | - if (blePairRequiresWriteNoResponse(serviceId, cid) && writable(item)) { | |
| 232 | - resolve({ | |
| 233 | - serviceId, | |
| 234 | - characteristicId: cid, | |
| 235 | - bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, item), | |
| 236 | - }) | |
| 237 | - return | |
| 238 | - } | |
| 239 | - } | |
| 240 | - const withResp = chars.find(hasBleWriteProperty) | |
| 241 | - const noResp = chars.find(hasBleWriteNoResponseProperty) | |
| 242 | - const target = withResp || noResp | |
| 243 | - if (target) { | |
| 244 | - resolve({ | |
| 245 | - serviceId, | |
| 246 | - characteristicId: String(target.uuid || ''), | |
| 247 | - bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, target), | |
| 248 | - }) | |
| 249 | - return | |
| 250 | - } | |
| 251 | - next(index + 1) | |
| 252 | - }, | |
| 253 | - fail: () => next(index + 1), | |
| 254 | - }) | |
| 255 | - } | |
| 256 | - next(0) | |
| 257 | - }, | |
| 258 | - fail: () => resolve(null), | |
| 259 | - }) | |
| 260 | - }) | |
| 261 | -} | |
| 262 | - | |
| 263 | 181 | function requestBleMtu (deviceId: string, preferredMtu: number): Promise<number> { |
| 264 | 182 | return new Promise((resolve) => { |
| 265 | 183 | const targetMtu = Math.max(20, Math.min(512, Math.round(preferredMtu || 20))) |
| ... | ... | @@ -286,7 +204,7 @@ function requestBleMtu (deviceId: string, preferredMtu: number): Promise<number> |
| 286 | 204 | |
| 287 | 205 | function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Promise<void> { |
| 288 | 206 | const finalizeExistingBleConnection = async () => { |
| 289 | - const write = await findBleWriteCharacteristic(device.deviceId) | |
| 207 | + const write = await discoverBleWriteCharacteristic(device.deviceId) | |
| 290 | 208 | if (!write) { |
| 291 | 209 | throw new Error('No writable characteristic found. This device may not support printing.') |
| 292 | 210 | } |
| ... | ... | @@ -329,21 +247,26 @@ function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Pr |
| 329 | 247 | |
| 330 | 248 | export async function connectBluetoothPrinter (device: PrinterCandidate): Promise<PrinterDriver> { |
| 331 | 249 | const driver = resolvePrinterDriver(device) |
| 332 | - if (driver.key === 'gp-d320fx') { | |
| 333 | - try { | |
| 334 | - await connectBlePrinter(device, driver) | |
| 335 | - } catch (_) { | |
| 250 | + try { | |
| 251 | + if (driver.key === 'gp-d320fx') { | |
| 252 | + try { | |
| 253 | + await connectBlePrinter(device, driver) | |
| 254 | + } catch (_) { | |
| 255 | + await connectClassicBluetooth(device, driver) | |
| 256 | + } | |
| 257 | + return driver | |
| 258 | + } | |
| 259 | + const resolvedType = driver.resolveConnectionType(device) | |
| 260 | + if (resolvedType === 'classic') { | |
| 336 | 261 | await connectClassicBluetooth(device, driver) |
| 262 | + } else { | |
| 263 | + await connectBlePrinter(device, driver) | |
| 337 | 264 | } |
| 338 | 265 | return driver |
| 266 | + } catch (error) { | |
| 267 | + clearPrinter() | |
| 268 | + throw error | |
| 339 | 269 | } |
| 340 | - const resolvedType = driver.resolveConnectionType(device) | |
| 341 | - if (resolvedType === 'classic') { | |
| 342 | - await connectClassicBluetooth(device, driver) | |
| 343 | - } else { | |
| 344 | - await connectBlePrinter(device, driver) | |
| 345 | - } | |
| 346 | - return driver | |
| 347 | 270 | } |
| 348 | 271 | |
| 349 | 272 | export function useBuiltinPrinter (driverKey = 'generic-tsc') { |
| ... | ... | @@ -469,11 +392,15 @@ export function canPrintCurrentLabelViaNativeFastJob (): boolean { |
| 469 | 392 | |
| 470 | 393 | /** |
| 471 | 394 | * 整页标签光栅是否一律走 canvasGetImageData(纯 JS 二值化),而不用「PNG → Bitmap.getPixels → readJavaIntArrayValue」。 |
| 472 | - * 内置 UPOS 一体机在部分 Uni 基座上 int[] 桥接读数恒为 0 → black=0、长时间重试/超时;所有 **打印机类型选内置** 的机型统一走本路径。 | |
| 473 | - * 若以后需按 Android 型号再细分,只改此函数即可。 | |
| 395 | + * - 内置 / Virtual BT:一体机 Bitmap int[] 桥接读数恒为 0 | |
| 396 | + * - 纯蓝牙 BLE(如 GP-D320FX-ble):手机基座 strip getPixels 同样全白 → RASTER_EMPTY_IMAGE | |
| 474 | 397 | */ |
| 475 | 398 | export function shouldRasterPrintViaCanvasImageData (): boolean { |
| 476 | - return getPrinterType() === 'builtin' | |
| 399 | + if (getPrinterType() === 'builtin') return true | |
| 400 | + if (isVirtualBtBluetoothConnection()) return true | |
| 401 | + const conn = getBluetoothConnection() | |
| 402 | + if (conn?.deviceType === 'ble') return true | |
| 403 | + return false | |
| 477 | 404 | } |
| 478 | 405 | |
| 479 | 406 | /** |
| ... | ... | @@ -702,10 +629,15 @@ export async function printLabelForCurrentPrinter ( |
| 702 | 629 | } |
| 703 | 630 | |
| 704 | 631 | /** |
| 705 | - * 界面仍可存 generic-tsc;内置 UPOS 实际需 ESC/POS 位图,故整页光栅强制 generic-esc(与预览像素一致,避免 TSPL 当文本打出)。 | |
| 632 | + * 界面仍可存 generic-tsc;内置 UPOS(rk3568 等)实际需 ESC/POS 位图。 | |
| 633 | + * D320FAX Virtual BT 佳博 SDK 连接为 TSC 模式,禁止转 ESC,否则 write 成功但不出纸。 | |
| 706 | 634 | */ |
| 707 | 635 | export function resolveRasterPrintDriver (driver: PrinterDriver): PrinterDriver { |
| 708 | - if (getPrinterType() === 'builtin' && driver.protocol === 'tsc') { | |
| 636 | + const useEscRaster = | |
| 637 | + getPrinterType() === 'builtin' | |
| 638 | + && !isVirtualBtBluetoothConnection() | |
| 639 | + && driver.protocol === 'tsc' | |
| 640 | + if (useEscRaster) { | |
| 709 | 641 | return getPrinterDriverByKey('generic-esc') |
| 710 | 642 | } |
| 711 | 643 | return driver |
| ... | ... | @@ -859,7 +791,7 @@ export async function printImageForCurrentPrinter ( |
| 859 | 791 | } |
| 860 | 792 | } |
| 861 | 793 | |
| 862 | - const rasterForPrint = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options) | |
| 794 | + const rasterForPrint = finalizeRasterForPrint(raster, rasterDriver.protocol, options) | |
| 863 | 795 | let data: number[] = [] |
| 864 | 796 | if (rasterDriver.protocol === 'esc') { |
| 865 | 797 | data = buildEscPosImageData(rasterForPrint, options) |
| ... | ... | @@ -936,6 +868,7 @@ export async function printImageForCurrentPrinter ( |
| 936 | 868 | } |
| 937 | 869 | } else { |
| 938 | 870 | try { |
| 871 | + const sendStageMs = isVirtualBtBluetoothConnection() ? 600000 : 180000 | |
| 939 | 872 | await withStageTimeout( |
| 940 | 873 | sendToPrinter(data, (p) => { |
| 941 | 874 | lastExternalProgressAt = Date.now() |
| ... | ... | @@ -944,7 +877,7 @@ export async function printImageForCurrentPrinter ( |
| 944 | 877 | printRunDiag('printImage_send_progress', { p }) |
| 945 | 878 | } |
| 946 | 879 | }), |
| 947 | - 180000, | |
| 880 | + sendStageMs, | |
| 948 | 881 | 'sendToPrinter' |
| 949 | 882 | ) |
| 950 | 883 | } finally { |
| ... | ... | @@ -972,12 +905,37 @@ export async function printImageDataForCurrentPrinter ( |
| 972 | 905 | const driver = getCurrentPrinterDriver() |
| 973 | 906 | const rasterDriver = resolveRasterPrintDriver(driver) |
| 974 | 907 | let raster = rasterizeImageData(imageData, options) |
| 975 | - raster = finalizeRasterForEscPrint(raster, rasterDriver.protocol, options) | |
| 976 | - if (onProgress) onProgress(5) | |
| 908 | + raster = finalizeRasterForPrint(raster, rasterDriver.protocol, options) | |
| 909 | + if (onProgress) onProgress(18) | |
| 977 | 910 | const data = rasterDriver.protocol === 'esc' |
| 978 | 911 | ? buildEscPosImageData(raster, options) |
| 979 | 912 | : buildTscImageData(raster, options, rasterDriver.imageDpi || 203) |
| 980 | - await sendToPrinter(data, onProgress) | |
| 913 | + let sendVisual = 22 | |
| 914 | + let lastExternalAt = Date.now() | |
| 915 | + const reportSend = (p: number) => { | |
| 916 | + if (!onProgress) return | |
| 917 | + const mapped = 22 + Math.round((Math.min(100, Math.max(0, p)) / 100) * 78) | |
| 918 | + if (mapped > sendVisual) { | |
| 919 | + sendVisual = mapped | |
| 920 | + onProgress(sendVisual) | |
| 921 | + } else { | |
| 922 | + lastExternalAt = Date.now() | |
| 923 | + } | |
| 924 | + } | |
| 925 | + const keepAlive = onProgress | |
| 926 | + ? setInterval(() => { | |
| 927 | + if (Date.now() - lastExternalAt < 1200) return | |
| 928 | + if (sendVisual >= 96) return | |
| 929 | + sendVisual += sendVisual < 80 ? 2 : 1 | |
| 930 | + onProgress(sendVisual) | |
| 931 | + }, 800) | |
| 932 | + : null | |
| 933 | + try { | |
| 934 | + await sendToPrinter(data, reportSend) | |
| 935 | + } finally { | |
| 936 | + if (keepAlive) clearInterval(keepAlive) | |
| 937 | + } | |
| 938 | + if (onProgress) onProgress(100) | |
| 981 | 939 | return driver |
| 982 | 940 | } |
| 983 | 941 | |
| ... | ... | @@ -1022,6 +980,33 @@ function finalizeRasterForEscPrint ( |
| 1022 | 980 | return trimMonochromeImageBottomWhitespace(raster, 8) |
| 1023 | 981 | } |
| 1024 | 982 | |
| 983 | +function finalizeRasterForPrint ( | |
| 984 | + raster: { width: number; height: number; pixels: number[] }, | |
| 985 | + protocol: string, | |
| 986 | + options: PrintImageOptions, | |
| 987 | +) { | |
| 988 | + let out = finalizeRasterForEscPrint(raster, protocol, options) | |
| 989 | + if (options.sharpenInvertedText) { | |
| 990 | + out = sharpenInvertedTextInMonochromeRaster(out) | |
| 991 | + } | |
| 992 | + return out | |
| 993 | +} | |
| 994 | + | |
| 995 | +function buildInvertedTextPrintImageOptions ( | |
| 996 | + template: SystemLabelTemplate, | |
| 997 | + base: PrintImageOptions = {}, | |
| 998 | +): PrintImageOptions { | |
| 999 | + if (!templateHasInvertedTextElements(template)) return base | |
| 1000 | + return { | |
| 1001 | + ...base, | |
| 1002 | + threshold: base.threshold ?? INVERTED_TEXT_IMAGE_THRESHOLD, | |
| 1003 | + bilinearImageScale: base.bilinearImageScale ?? false, | |
| 1004 | + sharpenInvertedText: base.sharpenInvertedText ?? true, | |
| 1005 | + density: base.density ?? 10, | |
| 1006 | + printSpeed: base.printSpeed ?? 4, | |
| 1007 | + } | |
| 1008 | +} | |
| 1009 | + | |
| 1025 | 1010 | export async function printSystemTemplateForCurrentPrinter ( |
| 1026 | 1011 | template: SystemLabelTemplate, |
| 1027 | 1012 | data: LabelTemplateData = {}, |
| ... | ... | @@ -1038,28 +1023,15 @@ export async function printSystemTemplateForCurrentPrinter ( |
| 1038 | 1023 | !!canvasRaster |
| 1039 | 1024 | && templateHasQrDataForCommandPrint(template) |
| 1040 | 1025 | && !templateHasUnsupportedElementsForCommandPrint(template) |
| 1026 | + && !templateRequiresCanvasStyleFidelity(template) | |
| 1041 | 1027 | |
| 1042 | 1028 | if (canvasRaster && !bypassCanvasRasterForQr) { |
| 1043 | 1029 | if (onProgress) onProgress(1) |
| 1044 | 1030 | const templateForDraw = templateForRasterPrint(template) |
| 1045 | 1031 | const maxDots = |
| 1046 | 1032 | rasterDriver.imageMaxWidthDots || (rasterDriver.protocol === 'esc' ? 384 : 576) |
| 1047 | - const escUseContentH = rasterDriver.protocol === 'esc' | |
| 1048 | - const contentHpx = escUseContentH ? templateContentHeightPx(templateForDraw) : undefined | |
| 1049 | - const layout = getLabelPrintRasterLayout( | |
| 1050 | - templateForDraw, | |
| 1051 | - maxDots, | |
| 1052 | - rasterDriver.imageDpi || 203, | |
| 1053 | - contentHpx != null ? { contentHeightPx: contentHpx } : undefined, | |
| 1054 | - ) | |
| 1055 | - if (escUseContentH) { | |
| 1056 | - printRunDiag('raster_layout_content_height', { | |
| 1057 | - contentHpx, | |
| 1058 | - layoutCh: layout.ch, | |
| 1059 | - outW: layout.outW, | |
| 1060 | - outH: layout.outH, | |
| 1061 | - }) | |
| 1062 | - } | |
| 1033 | + const printDpi = rasterDriver.imageDpi || 203 | |
| 1034 | + const layout = getLabelPrintRasterLayout(templateForDraw, maxDots, printDpi) | |
| 1063 | 1035 | if (onProgress) onProgress(4) |
| 1064 | 1036 | if (canvasRaster.applyLayout) { |
| 1065 | 1037 | await Promise.resolve(canvasRaster.applyLayout(layout)) |
| ... | ... | @@ -1067,24 +1039,17 @@ export async function printSystemTemplateForCurrentPrinter ( |
| 1067 | 1039 | if (onProgress) onProgress(7) |
| 1068 | 1040 | await settleAfterLabelCanvasResize() |
| 1069 | 1041 | if (onProgress) onProgress(9) |
| 1070 | - const printOpts: PrintImageOptions = { | |
| 1042 | + const layoutMm = rasterDotsToMillimeters(layout.outW, layout.outH, printDpi) | |
| 1043 | + const printOpts = buildInvertedTextPrintImageOptions(templateForDraw, { | |
| 1071 | 1044 | printQty: options.printQty || 1, |
| 1072 | 1045 | clearTopRasterRows: 0, |
| 1073 | 1046 | targetWidthDots: layout.outW, |
| 1074 | 1047 | targetHeightDots: layout.outH, |
| 1075 | - useContentHeight: escUseContentH, | |
| 1048 | + useContentHeight: false, | |
| 1076 | 1049 | cutBetweenCopies: true, |
| 1077 | - widthMm: templateSizeToMillimeters( | |
| 1078 | - templateForDraw.unit, | |
| 1079 | - Number(templateForDraw.width) || 0, | |
| 1080 | - Number(templateForDraw.height) || 0, | |
| 1081 | - ).widthMm, | |
| 1082 | - heightMm: templateSizeToMillimeters( | |
| 1083 | - templateForDraw.unit, | |
| 1084 | - Number(templateForDraw.width) || 0, | |
| 1085 | - Number(templateForDraw.height) || 0, | |
| 1086 | - ).heightMm, | |
| 1087 | - } | |
| 1050 | + widthMm: layoutMm.widthMm, | |
| 1051 | + heightMm: layoutMm.heightMm, | |
| 1052 | + }) | |
| 1088 | 1053 | const mapRasterProgress = onProgress |
| 1089 | 1054 | ? (p: number) => { |
| 1090 | 1055 | onProgress(12 + Math.round((Math.min(100, Math.max(0, p)) / 100) * 88)) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
| ... | ... | @@ -3,6 +3,14 @@ import type { |
| 3 | 3 | SystemTemplateElementBase, |
| 4 | 4 | SystemTemplateTextAlign, |
| 5 | 5 | } from './types/printer' |
| 6 | +import { readInvertColors } from '../invertColorsConfig' | |
| 7 | +import { readVerticalAlign, readFontWeight, readFontStyle, readTextDecoration } from '../textElementLayout' | |
| 8 | +import { | |
| 9 | + LABEL_EDITOR_FONT_FAMILY, | |
| 10 | + normalizeLabelEditorFontFamily, | |
| 11 | + resolveAndroidFontFilePath, | |
| 12 | + resolveLabelEditorFontFamily, | |
| 13 | +} from '../labelEditorFonts' | |
| 6 | 14 | |
| 7 | 15 | declare const plus: any |
| 8 | 16 | |
| ... | ... | @@ -172,7 +180,12 @@ function splitTextLines (text: string, paint: any, maxWidth: number): string[] { |
| 172 | 180 | return lines.length > 0 ? lines : [''] |
| 173 | 181 | } |
| 174 | 182 | |
| 175 | -export function shouldRasterizeTextElement (text: string, type: string): boolean { | |
| 183 | +export function shouldRasterizeTextElement ( | |
| 184 | + text: string, | |
| 185 | + type: string, | |
| 186 | + config: Record<string, unknown> = {} | |
| 187 | +): boolean { | |
| 188 | + if (readInvertColors(config)) return true | |
| 176 | 189 | const normalizedType = String(type || '').toUpperCase() |
| 177 | 190 | const normalizedText = normalizePrinterLikeText(text) |
| 178 | 191 | if (!normalizedText) return false |
| ... | ... | @@ -182,6 +195,11 @@ export function shouldRasterizeTextElement (text: string, type: string): boolean |
| 182 | 195 | */ |
| 183 | 196 | if (normalizedType === 'TEXT_PRICE') return true |
| 184 | 197 | if (/[€£¥¥éÉáàâäãåæçèêëìíîïñòóôöõøùúûüýÿœšž]/.test(normalizedText)) return true |
| 198 | + if (readFontStyle(config) === 'italic') return true | |
| 199 | + if (readTextDecoration(config) === 'underline') return true | |
| 200 | + if (readFontWeight(config) === 'bold') return true | |
| 201 | + const normalizedFont = normalizeLabelEditorFontFamily(config.fontFamily ?? config.FontFamily) | |
| 202 | + if (normalizedFont && normalizedFont !== LABEL_EDITOR_FONT_FAMILY) return true | |
| 185 | 203 | return /[^\x20-\x7E]/.test(normalizedText) |
| 186 | 204 | } |
| 187 | 205 | |
| ... | ... | @@ -208,18 +226,35 @@ export function createTextBitmapPatch (params: { |
| 208 | 226 | const height = Math.max(16, pxToDots(element.height, dpi) + TEXT_PADDING_DOTS * 2) |
| 209 | 227 | const bitmap = Bitmap.createBitmap(width, height, BitmapConfig.ARGB_8888) |
| 210 | 228 | const canvas = new Canvas(bitmap) |
| 211 | - canvas.drawColor(Color.WHITE) | |
| 229 | + const inverted = readInvertColors(config) | |
| 230 | + canvas.drawColor(inverted ? Color.BLACK : Color.WHITE) | |
| 212 | 231 | |
| 213 | 232 | const paint = new Paint() |
| 214 | 233 | paint.setAntiAlias(true) |
| 215 | 234 | paint.setDither(true) |
| 216 | - paint.setColor(Color.BLACK) | |
| 235 | + paint.setColor(inverted ? Color.WHITE : Color.BLACK) | |
| 217 | 236 | paint.setSubpixelText(true) |
| 218 | 237 | const fontSizeDots = Math.max(14, pxToDots(Number(config.fontSize || 14), dpi)) |
| 219 | 238 | paint.setTextSize(fontSizeDots) |
| 220 | - const isBold = String(config.fontWeight || '').toLowerCase() === 'bold' || String(element.type || '').toUpperCase() === 'TEXT_PRICE' | |
| 221 | - paint.setFakeBoldText(isBold) | |
| 222 | - paint.setTypeface(isBold ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT) | |
| 239 | + const family = resolveLabelEditorFontFamily(config) | |
| 240 | + const isBold = readFontWeight(config) === 'bold' || String(element.type || '').toUpperCase() === 'TEXT_PRICE' | |
| 241 | + const isItalic = readFontStyle(config) === 'italic' | |
| 242 | + const isUnderline = readTextDecoration(config) === 'underline' | |
| 243 | + const fontPath = resolveAndroidFontFilePath(family, isBold, isItalic) | |
| 244 | + let typeface = isBold ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT | |
| 245 | + if (fontPath && typeof Typeface.createFromFile === 'function') { | |
| 246 | + try { | |
| 247 | + const loaded = Typeface.createFromFile(fontPath) | |
| 248 | + if (loaded) typeface = loaded | |
| 249 | + } catch (_) { | |
| 250 | + /* use default */ | |
| 251 | + } | |
| 252 | + } | |
| 253 | + paint.setFakeBoldText(isBold && !fontPath) | |
| 254 | + if (typeof paint.setTextSkewX === 'function') { | |
| 255 | + paint.setTextSkewX(isItalic && !fontPath ? -0.25 : 0) | |
| 256 | + } | |
| 257 | + paint.setTypeface(typeface) | |
| 223 | 258 | |
| 224 | 259 | const maxTextWidth = Math.max(8, contentWidth) |
| 225 | 260 | const lines = splitTextLines(text, paint, maxTextWidth) |
| ... | ... | @@ -229,10 +264,13 @@ export function createTextBitmapPatch (params: { |
| 229 | 264 | Math.ceil(Math.abs(Number(fontMetrics.top)) + Math.abs(Number(fontMetrics.bottom)) + 2) |
| 230 | 265 | ) |
| 231 | 266 | const totalHeight = lines.length * lineHeight |
| 232 | - const isCenteredVertically = String(element.type || '').toUpperCase() === 'TEXT_PRICE' | |
| 233 | - const topOffset = isCenteredVertically | |
| 234 | - ? Math.max(TEXT_PADDING_DOTS, Math.floor((height - totalHeight) / 2)) | |
| 235 | - : TEXT_PADDING_DOTS | |
| 267 | + const verticalAlign = readVerticalAlign(config) | |
| 268 | + let topOffset = TEXT_PADDING_DOTS | |
| 269 | + if (verticalAlign === 'center') { | |
| 270 | + topOffset = Math.max(TEXT_PADDING_DOTS, Math.floor((height - totalHeight) / 2)) | |
| 271 | + } else if (verticalAlign === 'bottom') { | |
| 272 | + topOffset = Math.max(TEXT_PADDING_DOTS, height - totalHeight - TEXT_PADDING_DOTS) | |
| 273 | + } | |
| 236 | 274 | |
| 237 | 275 | for (let i = 0; i < lines.length; i++) { |
| 238 | 276 | const line = lines[i] |
| ... | ... | @@ -245,6 +283,15 @@ export function createTextBitmapPatch (params: { |
| 245 | 283 | } |
| 246 | 284 | const baseline = topOffset + i * lineHeight - Number(fontMetrics.top) |
| 247 | 285 | canvas.drawText(line, drawX, baseline, paint) |
| 286 | + if (isUnderline) { | |
| 287 | + const lineWidth = Number(paint.measureText(line)) | |
| 288 | + const underlineY = baseline + Math.max(2, Math.round(fontSizeDots * 0.08)) | |
| 289 | + const strokePaint = new Paint() | |
| 290 | + strokePaint.setColor(inverted ? Color.WHITE : Color.BLACK) | |
| 291 | + strokePaint.setStrokeWidth(Math.max(1, Math.round(fontSizeDots * 0.06))) | |
| 292 | + strokePaint.setStyle(graphics.Paint.Style.STROKE) | |
| 293 | + canvas.drawLine(drawX, underlineY, drawX + lineWidth, underlineY, strokePaint) | |
| 294 | + } | |
| 248 | 295 | } |
| 249 | 296 | |
| 250 | 297 | const image = bitmapToMonochromeImage(bitmap) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/nativeFastPrinter.ts
| ... | ... | @@ -158,24 +158,26 @@ export function getNativeFastPrinterDebugInfo () { |
| 158 | 158 | } |
| 159 | 159 | nativePlugin.getDebugInfo((payload: any) => { |
| 160 | 160 | const res = parsePluginResult(payload) |
| 161 | - const prev = { ...nativeFastPrinterState } | |
| 161 | + const prev = getNativeFastPrinterState() || {} | |
| 162 | 162 | const patch: NativePrinterResult = { |
| 163 | + ...prev, | |
| 163 | 164 | ...res, |
| 164 | 165 | lastAction: 'getDebugInfo', |
| 166 | + available: isNativeFastPrinterAvailable(), | |
| 165 | 167 | } |
| 166 | - /** getDebugInfo 常只带插件元数据;勿把上次 print 的 commandBytes/writeMs/stage 冲成 0 或空 */ | |
| 167 | - const resBytes = Number(res.commandBytes ?? 0) | |
| 168 | - const resWrite = Number(res.writeMs ?? 0) | |
| 169 | - const prevBytes = Number(prev.commandBytes ?? 0) | |
| 170 | - if ((resBytes <= 0 && resWrite <= 0) && prevBytes > 0) { | |
| 171 | - patch.commandBytes = prev.commandBytes | |
| 172 | - patch.writeMs = prev.writeMs | |
| 173 | - if (res.stage == null || String(res.stage).trim() === '') { | |
| 174 | - patch.stage = prev.stage | |
| 175 | - } | |
| 168 | + const resStage = String(res.stage ?? '').trim() | |
| 169 | + if (resStage) patch.stage = resStage | |
| 170 | + if (res.writeMs != null && Number.isFinite(Number(res.writeMs))) { | |
| 171 | + patch.writeMs = Number(res.writeMs) | |
| 172 | + } | |
| 173 | + if (res.commandBytes != null && Number(res.commandBytes) > 0) { | |
| 174 | + patch.commandBytes = Number(res.commandBytes) | |
| 175 | + } | |
| 176 | + if (res.lastError != null && String(res.lastError).trim()) { | |
| 177 | + patch.lastError = String(res.lastError) | |
| 176 | 178 | } |
| 177 | 179 | updateNativeState(patch) |
| 178 | - resolve(res) | |
| 180 | + resolve({ ...patch }) | |
| 179 | 181 | }) |
| 180 | 182 | } catch (error: any) { |
| 181 | 183 | reject(error instanceof Error ? error : new Error(String(error || 'NATIVE_FAST_PRINTER_DEBUG_FAILED'))) |
| ... | ... | @@ -351,6 +353,51 @@ export function isNativePrintCommandBytesSupported (): boolean { |
| 351 | 353 | return !!plugin && typeof plugin.printCommandBytes === 'function' |
| 352 | 354 | } |
| 353 | 355 | |
| 356 | +function sleepMs (ms: number): Promise<void> { | |
| 357 | + return new Promise((resolve) => setTimeout(resolve, ms)) | |
| 358 | +} | |
| 359 | + | |
| 360 | +/** | |
| 361 | + * printCommandBytes 原生仅首帧回调 queued 即 success,真实写出在后台线程。 | |
| 362 | + * 轮询 debugInfo 直到 printCommandBytes:ok / writeMs 增长 / :error。 | |
| 363 | + */ | |
| 364 | +export async function waitForNativeCommandBytesWriteComplete ( | |
| 365 | + timeoutMs = 300000, | |
| 366 | + onPoll?: (percentInWait: number) => void, | |
| 367 | +): Promise<{ writeMs: number; commandBytes: number; stage: string }> { | |
| 368 | + const started = Date.now() | |
| 369 | + const baselineWriteMs = Number(getNativeFastPrinterState()?.writeMs || 0) | |
| 370 | + while (Date.now() - started < timeoutMs) { | |
| 371 | + try { | |
| 372 | + await getNativeFastPrinterDebugInfo() | |
| 373 | + } catch (_) { | |
| 374 | + /* 单次 getDebugInfo 失败不中断,继续读缓存 state */ | |
| 375 | + } | |
| 376 | + const info = getNativeFastPrinterState() || {} | |
| 377 | + const stage = String(info.stage || '').toLowerCase() | |
| 378 | + const writeMs = Number(info.writeMs || 0) | |
| 379 | + const commandBytes = Number(info.commandBytes || 0) | |
| 380 | + | |
| 381 | + if (stage.includes('printcommandbytes:error')) { | |
| 382 | + throw new Error(String(info.lastError || 'NATIVE_PRINT_COMMAND_BYTES_ERROR')) | |
| 383 | + } | |
| 384 | + if (stage.includes('printcommandbytes:ok')) { | |
| 385 | + return { writeMs, commandBytes, stage: String(info.stage || '') } | |
| 386 | + } | |
| 387 | + /** 部分 AAR stage 字段缺失,但 writeMs 相对 queued 前已增长 → 纸已出 */ | |
| 388 | + if (writeMs > baselineWriteMs && writeMs > 0) { | |
| 389 | + return { writeMs, commandBytes, stage: stage || 'printCommandBytes:ok' } | |
| 390 | + } | |
| 391 | + if (onPoll) { | |
| 392 | + const elapsed = Date.now() - started | |
| 393 | + const cap = Math.min(timeoutMs, 90000) | |
| 394 | + onPoll(Math.min(99, Math.round((elapsed / cap) * 100))) | |
| 395 | + } | |
| 396 | + await sleepMs(150) | |
| 397 | + } | |
| 398 | + throw new Error('NATIVE_PRINT_COMMAND_BYTES_WRITE_TIMEOUT') | |
| 399 | +} | |
| 400 | + | |
| 354 | 401 | export function printNativeUposCommandBytes (options: { |
| 355 | 402 | base64: string |
| 356 | 403 | prefer?: 'builtin' | 'serial' | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
| ... | ... | @@ -9,6 +9,22 @@ import type { |
| 9 | 9 | } from './types/printer' |
| 10 | 10 | import { formatBarcodeValueForTsc, normalizeBarcodeType } from '../barcodeFormat' |
| 11 | 11 | import { applyTemplateData } from './templateRenderer' |
| 12 | +import { | |
| 13 | + isCorruptedDateDisplayFormat, | |
| 14 | + parseStoredPrintInputOffset, | |
| 15 | + resolveElementDateTimeDisplay, | |
| 16 | +} from '../labelPreview/printInputOffset' | |
| 17 | +import { readInvertColors } from '../invertColorsConfig' | |
| 18 | +import { normalizeTemplatePrintOrientation } from '../labelPreview/normalizePreviewTemplate' | |
| 19 | +import { | |
| 20 | + readElementBorder, | |
| 21 | + readElementRotation, | |
| 22 | + readFontStyle, | |
| 23 | + readFontWeight, | |
| 24 | + readTextDecoration, | |
| 25 | + readVerticalAlign, | |
| 26 | +} from '../textElementLayout' | |
| 27 | +import { normalizeLabelEditorFontFamily, LABEL_EDITOR_FONT_FAMILY } from '../labelEditorFonts' | |
| 12 | 28 | |
| 13 | 29 | function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { |
| 14 | 30 | const type = String(el.type || '').toUpperCase() |
| ... | ... | @@ -88,46 +104,27 @@ function prepareBarcodeElementForNativePrint (el: SystemTemplateElementBase): Sy |
| 88 | 104 | return { ...el, config: cfg } |
| 89 | 105 | } |
| 90 | 106 | |
| 107 | +function readNativeSnapshotText (config: Record<string, any>): string { | |
| 108 | + for (const key of ['__previewFormatted', '__PreviewFormatted', 'text', 'Text'] as const) { | |
| 109 | + const raw = String(config[key] ?? '').trim() | |
| 110 | + if (!raw) continue | |
| 111 | + if (parseStoredPrintInputOffset(raw)) continue | |
| 112 | + if (isCorruptedDateDisplayFormat(raw)) continue | |
| 113 | + return raw | |
| 114 | + } | |
| 115 | + return '' | |
| 116 | +} | |
| 117 | + | |
| 91 | 118 | /** |
| 92 | 119 | * 将 WEIGHT / DATE / TIME / DURATION 转为 TEXT_STATIC(展示文案与合并后的 config.text 一致), |
| 93 | 120 | * LOGO → IMAGE,使同一套模板可走 native printTemplate,避免仅因元素类型名而整页光栅(进度长期停在 ~12–14%)。 |
| 94 | 121 | */ |
| 95 | 122 | export function normalizeTemplateForNativeFastJob ( |
| 96 | 123 | template: SystemLabelTemplate, |
| 97 | - data: LabelTemplateData | |
| 124 | + data: LabelTemplateData, | |
| 125 | + baseTime: Date = new Date(), | |
| 98 | 126 | ): SystemLabelTemplate { |
| 99 | - const now = new Date() | |
| 100 | - const pad2 = (n: number): string => String(n).padStart(2, '0') | |
| 101 | - const formatDateByPreset = (fmt: string, d: Date): string => { | |
| 102 | - const yyyy = String(d.getFullYear()) | |
| 103 | - const yy = yyyy.slice(-2) | |
| 104 | - const mm = pad2(d.getMonth() + 1) | |
| 105 | - const dd = pad2(d.getDate()) | |
| 106 | - const hh = pad2(d.getHours()) | |
| 107 | - const min = pad2(d.getMinutes()) | |
| 108 | - switch (fmt) { | |
| 109 | - case 'DD/MM/YYYY': return `${dd}/${mm}/${yyyy}` | |
| 110 | - case 'MM/DD/YYYY': return `${mm}/${dd}/${yyyy}` | |
| 111 | - case 'DD/MM/YY': return `${dd}/${mm}/${yy}` | |
| 112 | - case 'MM/DD/YY': return `${mm}/${dd}/${yy}` | |
| 113 | - case 'MM/YY': return `${mm}/${yy}` | |
| 114 | - case 'MM/DD': return `${mm}/${dd}` | |
| 115 | - case 'MM': return mm | |
| 116 | - case 'DD': return dd | |
| 117 | - case 'YY': return yy | |
| 118 | - case 'YYYY-MM-DD': return `${yyyy}-${mm}-${dd}` | |
| 119 | - case 'YYYY-MM-DD HH:mm': return `${yyyy}-${mm}-${dd} ${hh}:${min}` | |
| 120 | - case 'HH:mm': return `${hh}:${min}` | |
| 121 | - default: | |
| 122 | - return String(fmt || '') | |
| 123 | - .replace('YYYY', yyyy) | |
| 124 | - .replace('YY', yy) | |
| 125 | - .replace('MM', mm) | |
| 126 | - .replace('DD', dd) | |
| 127 | - .replace('HH', hh) | |
| 128 | - .replace('mm', min) | |
| 129 | - } | |
| 130 | - } | |
| 127 | + const now = baseTime | |
| 131 | 128 | |
| 132 | 129 | const extras: SystemTemplateElementBase[] = [] |
| 133 | 130 | const elements = (template.elements || []).map((el) => { |
| ... | ... | @@ -138,41 +135,18 @@ export function normalizeTemplateForNativeFastJob ( |
| 138 | 135 | } |
| 139 | 136 | if (type === 'WEIGHT' || type === 'DATE' || type === 'TIME' || type === 'DURATION') { |
| 140 | 137 | let text = '' |
| 141 | - /** 与 renderLabelPreviewCanvas.previewTextForElement 一致:后端 AUTO_DB 的 DATE/TIME 常把算好的展示串写在 format 而非 text */ | |
| 142 | - let raw = String(config.text ?? config.Text ?? '').trim() | |
| 143 | - const vst = String(el.valueSourceType || '').toUpperCase() | |
| 144 | - const inputType = String(config.inputType ?? config.InputType ?? '').toLowerCase() | |
| 145 | - if (type === 'DATE') { | |
| 146 | - if (raw) { | |
| 147 | - text = applyTemplateData(raw, data) | |
| 148 | - } else if (vst === 'PRINT_INPUT' && (inputType === 'date' || inputType === 'datetime')) { | |
| 149 | - const fmt = String(config.format ?? config.Format ?? (inputType === 'datetime' ? 'YYYY-MM-DD HH:mm' : 'DD/MM/YYYY')).trim() | |
| 150 | - text = fmt | |
| 138 | + if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { | |
| 139 | + const snap = readNativeSnapshotText(config) | |
| 140 | + if (snap) { | |
| 141 | + text = applyTemplateData(snap, data) | |
| 151 | 142 | } else { |
| 152 | - const fmt = String(config.format ?? config.Format ?? 'DD/MM/YYYY').trim() || 'DD/MM/YYYY' | |
| 153 | - const offset = Number(config.offsetDays ?? config.OffsetDays ?? 0) || 0 | |
| 154 | - const d = new Date(now.getTime()) | |
| 155 | - d.setDate(d.getDate() + offset) | |
| 156 | - text = formatDateByPreset(fmt, d) | |
| 157 | - } | |
| 158 | - } else if (type === 'TIME') { | |
| 159 | - if (raw) { | |
| 160 | - text = applyTemplateData(raw, data) | |
| 161 | - } else if (vst === 'PRINT_INPUT') { | |
| 162 | - text = String(config.format ?? config.Format ?? 'HH:mm').trim() || 'HH:mm' | |
| 163 | - } else { | |
| 164 | - text = formatDateByPreset('HH:mm', now) | |
| 165 | - } | |
| 166 | - } else if (type === 'DURATION') { | |
| 167 | - if (raw) { | |
| 168 | - text = applyTemplateData(raw, data) | |
| 169 | - } else { | |
| 170 | - const unit = String(config.format ?? config.Format ?? 'Days').trim() || 'Days' | |
| 171 | - const rawV = config.durationValue ?? config.value ?? config.offsetDays ?? config.DurationValue ?? config.Value ?? config.OffsetDays | |
| 172 | - const val = Number.isFinite(Number(rawV)) ? Number(rawV) : 0 | |
| 173 | - text = `${val} ${unit}`.trim() | |
| 143 | + const live = resolveElementDateTimeDisplay(el, now) | |
| 144 | + if (live != null && live.trim()) { | |
| 145 | + text = applyTemplateData(live, data) | |
| 146 | + } | |
| 174 | 147 | } |
| 175 | 148 | } else if (type === 'WEIGHT') { |
| 149 | + let raw = String(config.text ?? config.Text ?? '').trim() | |
| 176 | 150 | if (!raw) raw = String(config.value ?? config.Value ?? '').trim() |
| 177 | 151 | if (raw) text = applyTemplateData(raw, data) |
| 178 | 152 | } |
| ... | ... | @@ -183,10 +157,14 @@ export function normalizeTemplateForNativeFastJob ( |
| 183 | 157 | if (v && u && !v.endsWith(u)) text = `${v}${u}` |
| 184 | 158 | else text = v || u |
| 185 | 159 | } |
| 160 | + const nextConfig: Record<string, unknown> = { ...config, text, nativeSourceType: type } | |
| 161 | + if (readInvertColors(config)) { | |
| 162 | + nextConfig.forceRasterText = true | |
| 163 | + } | |
| 186 | 164 | return { |
| 187 | 165 | ...el, |
| 188 | 166 | type: 'TEXT_STATIC' as typeof el.type, |
| 189 | - config: { ...config, text, nativeSourceType: type }, | |
| 167 | + config: nextConfig, | |
| 190 | 168 | } |
| 191 | 169 | } |
| 192 | 170 | if (type === 'NUTRITION') { |
| ... | ... | @@ -222,7 +200,14 @@ export function normalizeTemplateForNativeFastJob ( |
| 222 | 200 | } |
| 223 | 201 | return el |
| 224 | 202 | }) |
| 225 | - const merged = resolveNativeLayoutCollisions([...elements, ...extras]) | |
| 203 | + const withInvertRaster = elements.map((el) => { | |
| 204 | + const cfg = { ...(el.config || {}) } as Record<string, unknown> | |
| 205 | + if (readInvertColors(cfg)) { | |
| 206 | + cfg.forceRasterText = true | |
| 207 | + } | |
| 208 | + return { ...el, config: cfg } | |
| 209 | + }) | |
| 210 | + const merged = resolveNativeLayoutCollisions([...withInvertRaster, ...extras]) | |
| 226 | 211 | return { ...template, elements: merged } |
| 227 | 212 | } |
| 228 | 213 | |
| ... | ... | @@ -233,3 +218,59 @@ export function templateHasUnsupportedNativeFastElements (template: SystemLabelT |
| 233 | 218 | } |
| 234 | 219 | return false |
| 235 | 220 | } |
| 221 | + | |
| 222 | +/** DATE/TIME/DURATION 在原生 printTemplate 中易丢 config.text,须与预览同源走 Canvas 光栅 */ | |
| 223 | +export function templateHasDateTimeElements (template: SystemLabelTemplate): boolean { | |
| 224 | + for (const el of template.elements || []) { | |
| 225 | + const type = String(el.type || '').toUpperCase() | |
| 226 | + if (type === 'DATE' || type === 'TIME' || type === 'DURATION') return true | |
| 227 | + } | |
| 228 | + return false | |
| 229 | +} | |
| 230 | + | |
| 231 | +/** 元素边框 / 黑底白字 / 斜体下划线 / 自定义字体 / 垂直对齐 / 竖排 / 横打等,原生路径无法完整还原时须走 canvas 光栅 */ | |
| 232 | +export function templateRequiresCanvasStyleFidelity (template: SystemLabelTemplate): boolean { | |
| 233 | + const paperBorder = String(template.border ?? (template as any).Border ?? '').trim().toLowerCase() | |
| 234 | + if (paperBorder === 'line' || paperBorder === 'dotted' || paperBorder === 'solid') { | |
| 235 | + return true | |
| 236 | + } | |
| 237 | + if (normalizeTemplatePrintOrientation(template.printOrientation) === 'horizontal') { | |
| 238 | + return true | |
| 239 | + } | |
| 240 | + | |
| 241 | + for (const el of template.elements || []) { | |
| 242 | + const border = readElementBorder(el) | |
| 243 | + const type = String(el.type || '').toUpperCase() | |
| 244 | + if (border === 'line' || border === 'dotted' || border === 'solid') { | |
| 245 | + return true | |
| 246 | + } | |
| 247 | + if (readElementRotation(el) !== 'horizontal') { | |
| 248 | + return true | |
| 249 | + } | |
| 250 | + const cfg = (el.config || {}) as Record<string, unknown> | |
| 251 | + if (readInvertColors(cfg)) { | |
| 252 | + return true | |
| 253 | + } | |
| 254 | + if (readFontStyle(cfg) === 'italic') { | |
| 255 | + return true | |
| 256 | + } | |
| 257 | + if (readTextDecoration(cfg) === 'underline') { | |
| 258 | + return true | |
| 259 | + } | |
| 260 | + if (readFontWeight(cfg) === 'bold') { | |
| 261 | + return true | |
| 262 | + } | |
| 263 | + if (readVerticalAlign(cfg) !== 'top') { | |
| 264 | + return true | |
| 265 | + } | |
| 266 | + const normalizedFont = normalizeLabelEditorFontFamily(cfg.fontFamily ?? cfg.FontFamily) | |
| 267 | + if (normalizedFont && normalizedFont !== LABEL_EDITOR_FONT_FAMILY) { | |
| 268 | + return true | |
| 269 | + } | |
| 270 | + /** 营养表使用独立排版,原生与 canvas 字体/间距易不一致 */ | |
| 271 | + if (type === 'NUTRITION') { | |
| 272 | + return true | |
| 273 | + } | |
| 274 | + } | |
| 275 | + return false | |
| 276 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/printRunDiagnostics.ts
| ... | ... | @@ -42,3 +42,30 @@ export function getPrintRunDiagnosticsTextForModal (maxChars = 3800): string { |
| 42 | 42 | if (full.length <= maxChars) return full |
| 43 | 43 | return `...(start omitted, ${full.length - maxChars} chars)\n\n${full.slice(-maxChars)}` |
| 44 | 44 | } |
| 45 | + | |
| 46 | +const STORAGE_LAST_PRINT_DIAG = 'lastPrintRunDiagnostics' | |
| 47 | +const STORAGE_LAST_PRINT_DIAG_AT = 'lastPrintRunDiagnosticsAt' | |
| 48 | + | |
| 49 | +/** 打印结束后写入 storage,Printer 页可查看最近一次任务时间线 */ | |
| 50 | +export function persistPrintRunDiagnostics (): void { | |
| 51 | + try { | |
| 52 | + uni.setStorageSync(STORAGE_LAST_PRINT_DIAG, getPrintRunDiagnosticsText()) | |
| 53 | + uni.setStorageSync(STORAGE_LAST_PRINT_DIAG_AT, String(Date.now())) | |
| 54 | + } catch (_) {} | |
| 55 | +} | |
| 56 | + | |
| 57 | +export function getPersistedPrintRunDiagnostics (): string { | |
| 58 | + try { | |
| 59 | + return String(uni.getStorageSync(STORAGE_LAST_PRINT_DIAG) || '') | |
| 60 | + } catch (_) { | |
| 61 | + return '' | |
| 62 | + } | |
| 63 | +} | |
| 64 | + | |
| 65 | +export function getPersistedPrintRunDiagnosticsAt (): number { | |
| 66 | + try { | |
| 67 | + return Number(uni.getStorageSync(STORAGE_LAST_PRINT_DIAG_AT) || 0) || 0 | |
| 68 | + } catch (_) { | |
| 69 | + return 0 | |
| 70 | + } | |
| 71 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/printerConnection.ts
| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | */ |
| 4 | 4 | import type { ActiveBtDeviceType, PrinterType } from './types/printer' |
| 5 | 5 | import classicBluetooth from './bluetoothTool.js' |
| 6 | -import { getDeviceFingerprint } from '../deviceInfo' | |
| 6 | +import { getDeviceFingerprint, isAoaAioDevice } from '../deviceInfo' | |
| 7 | 7 | import { |
| 8 | 8 | blePairRequiresWriteNoResponse, |
| 9 | 9 | isNordicUartStyleBleService, |
| ... | ... | @@ -19,6 +19,7 @@ import { |
| 19 | 19 | isNativePrintCommandBytesSupported, |
| 20 | 20 | printNativeCommandBytes, |
| 21 | 21 | printNativeUposCommandBytes, |
| 22 | + waitForNativeCommandBytesWriteComplete, | |
| 22 | 23 | } from './nativeFastPrinter' |
| 23 | 24 | import { printRunDiag } from './printRunDiagnostics' |
| 24 | 25 | |
| ... | ... | @@ -43,9 +44,9 @@ const STORAGE_PRINTER_DEBUG_LOG = 'printerDebugLog' // '1' | '0' |
| 43 | 44 | const BUILTIN_PROBE_PORTS = [9100, 4000, 9000, 6000] |
| 44 | 45 | const BUILTIN_PRINTER_DEVICE_KEYWORDS: string[] = [ |
| 45 | 46 | // 在这里补充需要走 Built-in 的设备型号关键字(小写匹配) |
| 46 | - // 例如:'desktop-aio', 'pos-terminal-x1' | |
| 47 | 47 | 'rk3568', |
| 48 | 48 | 'aoa_rk3568', |
| 49 | + 'aoa', | |
| 49 | 50 | ] |
| 50 | 51 | export type BtDeviceType = ActiveBtDeviceType |
| 51 | 52 | |
| ... | ... | @@ -185,7 +186,9 @@ type PrinterDebugSnapshot = { |
| 185 | 186 | uposSupported: boolean |
| 186 | 187 | uposOptions: ReturnType<typeof getStoredUposPrintOptions> |
| 187 | 188 | keywordMatched: boolean |
| 189 | + virtualBtLinked: boolean | |
| 188 | 190 | uposWillTry: boolean |
| 191 | + sendChannelHint: string | |
| 189 | 192 | tcpPluginAvailable: boolean |
| 190 | 193 | savedPort: number |
| 191 | 194 | } |
| ... | ... | @@ -208,8 +211,17 @@ export async function getPrinterDebugSnapshot (options?: { |
| 208 | 211 | const uposOptions = getUposOptionsFromStorage() |
| 209 | 212 | const keywordMatched = !!deviceFingerprint |
| 210 | 213 | && (deviceFingerprint.includes('rk3568') || deviceFingerprint.includes('aoa_rk3568')) |
| 214 | + const virtualBtLinked = !!bluetoothConnection | |
| 215 | + && String(bluetoothConnection.deviceName || '').toLowerCase().includes('virtual bt') | |
| 216 | + const isD320faxDevice = !!deviceFingerprint | |
| 217 | + && (deviceFingerprint.includes('d320fax') || deviceFingerprint.includes('msm8953')) | |
| 211 | 218 | const uposSupported = isNativeUposPrintSupported() |
| 212 | - const uposWillTry = uposSupported && (uposOptions.force || keywordMatched) | |
| 219 | + const uposWillTry = uposSupported && ( | |
| 220 | + uposOptions.force | |
| 221 | + || (keywordMatched && !isD320faxDevice) | |
| 222 | + || (virtualBtLinked && !isD320faxDevice) | |
| 223 | + || (printerType === 'builtin' && !isD320faxDevice) | |
| 224 | + ) | |
| 213 | 225 | |
| 214 | 226 | let tcpPluginAvailable = false |
| 215 | 227 | try { |
| ... | ... | @@ -241,7 +253,9 @@ export async function getPrinterDebugSnapshot (options?: { |
| 241 | 253 | uposSupported, |
| 242 | 254 | uposOptions, |
| 243 | 255 | keywordMatched, |
| 256 | + virtualBtLinked, | |
| 244 | 257 | uposWillTry, |
| 258 | + sendChannelHint: resolvePrintSendChannelHint(), | |
| 245 | 259 | tcpPluginAvailable, |
| 246 | 260 | savedPort, |
| 247 | 261 | }, |
| ... | ... | @@ -263,6 +277,153 @@ export async function logPrinterDebug (options?: { |
| 263 | 277 | |
| 264 | 278 | const BLE_MTU_DEFAULT = 20 |
| 265 | 279 | |
| 280 | +function hasBleWriteProperty (item: any): boolean { | |
| 281 | + const w = item.properties?.write | |
| 282 | + return w === true || w === 'true' | |
| 283 | +} | |
| 284 | + | |
| 285 | +function hasBleWriteNoResponseProperty (item: any): boolean { | |
| 286 | + const p = item.properties || {} | |
| 287 | + return ( | |
| 288 | + p.writeNoResponse === true || | |
| 289 | + p.writeNoResponse === 'true' || | |
| 290 | + p.writeWithoutResponse === true || | |
| 291 | + p.writeWithoutResponse === 'true' | |
| 292 | + ) | |
| 293 | +} | |
| 294 | + | |
| 295 | +/** | |
| 296 | + * Nordic 白名单:GATT 可能只报 write,但机芯数据口仍须 writeNoResponse(长任务带响应写易 mid-job 10007)。 | |
| 297 | + * 非白名单:按 GATT 属性选择。 | |
| 298 | + */ | |
| 299 | +function pickBleWriteUsesNoResponse (serviceId: string, item: any): boolean { | |
| 300 | + const cid = String(item.uuid || '') | |
| 301 | + const hw = hasBleWriteProperty(item) | |
| 302 | + const hn = hasBleWriteNoResponseProperty(item) | |
| 303 | + if (blePairRequiresWriteNoResponse(serviceId, cid)) { | |
| 304 | + return hw || hn | |
| 305 | + } | |
| 306 | + if (!hn) return false | |
| 307 | + if (!hw) return true | |
| 308 | + return false | |
| 309 | +} | |
| 310 | + | |
| 311 | +function listBleCharacteristics ( | |
| 312 | + deviceId: string, | |
| 313 | + serviceId: string, | |
| 314 | +): Promise<Array<{ uuid?: string; properties?: Record<string, unknown> }>> { | |
| 315 | + return new Promise((resolve) => { | |
| 316 | + uni.getBLEDeviceCharacteristics({ | |
| 317 | + deviceId, | |
| 318 | + serviceId, | |
| 319 | + success: (charRes) => resolve((charRes.characteristics || []) as Array<{ uuid?: string; properties?: Record<string, unknown> }>), | |
| 320 | + fail: () => resolve([]), | |
| 321 | + }) | |
| 322 | + }) | |
| 323 | +} | |
| 324 | + | |
| 325 | +function resolveBleWriteTargetFromChars ( | |
| 326 | + serviceId: string, | |
| 327 | + chars: Array<{ uuid?: string; properties?: Record<string, unknown> }>, | |
| 328 | +): { serviceId: string; characteristicId: string; bleWriteUsesNoResponse: boolean } | null { | |
| 329 | + const writable = (item: { properties?: Record<string, unknown> }) => | |
| 330 | + hasBleWriteProperty(item) || hasBleWriteNoResponseProperty(item) | |
| 331 | + for (const item of chars) { | |
| 332 | + const cid = String(item.uuid || '') | |
| 333 | + if (blePairRequiresWriteNoResponse(serviceId, cid) && writable(item)) { | |
| 334 | + return { | |
| 335 | + serviceId, | |
| 336 | + characteristicId: cid, | |
| 337 | + bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, item), | |
| 338 | + } | |
| 339 | + } | |
| 340 | + } | |
| 341 | + return null | |
| 342 | +} | |
| 343 | + | |
| 344 | +function resolveBleGenericWriteFromChars ( | |
| 345 | + serviceId: string, | |
| 346 | + chars: Array<{ uuid?: string; properties?: Record<string, unknown> }>, | |
| 347 | +): { serviceId: string; characteristicId: string; bleWriteUsesNoResponse: boolean } | null { | |
| 348 | + const withResp = chars.find(hasBleWriteProperty) | |
| 349 | + const noResp = chars.find(hasBleWriteNoResponseProperty) | |
| 350 | + const target = withResp || noResp | |
| 351 | + if (!target) return null | |
| 352 | + return { | |
| 353 | + serviceId, | |
| 354 | + characteristicId: String(target.uuid || ''), | |
| 355 | + bleWriteUsesNoResponse: pickBleWriteUsesNoResponse(serviceId, target), | |
| 356 | + } | |
| 357 | +} | |
| 358 | + | |
| 359 | +/** 连接/打印前重新发现可写特征,避免 storage 里仍是错误写入方式 */ | |
| 360 | +export function discoverBleWriteCharacteristic (deviceId: string): Promise<{ | |
| 361 | + serviceId: string | |
| 362 | + characteristicId: string | |
| 363 | + bleWriteUsesNoResponse: boolean | |
| 364 | +} | null> { | |
| 365 | + return new Promise((resolve) => { | |
| 366 | + uni.getBLEDeviceServices({ | |
| 367 | + deviceId, | |
| 368 | + success: async (serviceRes) => { | |
| 369 | + const services = serviceRes.services || [] | |
| 370 | + try { | |
| 371 | + /** 第一轮:只认 Nordic 打印串口,避免误选其它服务的可写特征导致 property not support */ | |
| 372 | + for (const svc of services) { | |
| 373 | + const serviceId = String(svc.uuid || '') | |
| 374 | + const chars = await listBleCharacteristics(deviceId, serviceId) | |
| 375 | + const nordic = resolveBleWriteTargetFromChars(serviceId, chars) | |
| 376 | + if (nordic) { | |
| 377 | + console.log('[BLE] discover: nordic uart', nordic) | |
| 378 | + resolve(nordic) | |
| 379 | + return | |
| 380 | + } | |
| 381 | + } | |
| 382 | + for (const svc of services) { | |
| 383 | + const serviceId = String(svc.uuid || '') | |
| 384 | + const chars = await listBleCharacteristics(deviceId, serviceId) | |
| 385 | + const generic = resolveBleGenericWriteFromChars(serviceId, chars) | |
| 386 | + if (generic) { | |
| 387 | + console.log('[BLE] discover: generic writable', generic) | |
| 388 | + resolve(generic) | |
| 389 | + return | |
| 390 | + } | |
| 391 | + } | |
| 392 | + resolve(null) | |
| 393 | + } catch { | |
| 394 | + resolve(null) | |
| 395 | + } | |
| 396 | + }, | |
| 397 | + fail: () => resolve(null), | |
| 398 | + }) | |
| 399 | + }) | |
| 400 | +} | |
| 401 | + | |
| 402 | +async function refreshBlePrintTarget (deviceId: string, driverKey: string): Promise<{ | |
| 403 | + serviceId: string | |
| 404 | + characteristicId: string | |
| 405 | + bleWriteUsesNoResponse: boolean | |
| 406 | +} | null> { | |
| 407 | + const write = await discoverBleWriteCharacteristic(deviceId) | |
| 408 | + if (!write) return null | |
| 409 | + const driver = getPrinterDriverByKey(driverKey) | |
| 410 | + await ensureBleUartNotifyIfNeeded(deviceId, write.serviceId, write.characteristicId) | |
| 411 | + const negotiatedMtu = await requestBleMtuNegotiation(deviceId, driver.preferredBleMtu || BLE_MTU_DEFAULT) | |
| 412 | + setBluetoothConnection({ | |
| 413 | + deviceId, | |
| 414 | + deviceName: uni.getStorageSync(STORAGE_BT_DEVICE_NAME) || 'Bluetooth Printer', | |
| 415 | + serviceId: write.serviceId, | |
| 416 | + characteristicId: write.characteristicId, | |
| 417 | + deviceType: 'ble', | |
| 418 | + mtu: negotiatedMtu, | |
| 419 | + driverKey, | |
| 420 | + bleWriteUsesNoResponse: blePairRequiresWriteNoResponse(write.serviceId, write.characteristicId) | |
| 421 | + ? true | |
| 422 | + : write.bleWriteUsesNoResponse, | |
| 423 | + }) | |
| 424 | + return write | |
| 425 | +} | |
| 426 | + | |
| 266 | 427 | export function getPrinterType (): PrinterType | '' { |
| 267 | 428 | const type = (uni.getStorageSync(STORAGE_PRINTER_TYPE) as PrinterType) || '' |
| 268 | 429 | if (!type) return '' |
| ... | ... | @@ -389,6 +550,8 @@ export function isBuiltinPrinterAvailable (): boolean { |
| 389 | 550 | // #endif |
| 390 | 551 | } |
| 391 | 552 | |
| 553 | +export { isAoaAioDevice } from '../deviceInfo' | |
| 554 | + | |
| 392 | 555 | export function isBuiltinPrinterEnabledByDeviceModel (): boolean { |
| 393 | 556 | const fingerprint = getDeviceFingerprint() |
| 394 | 557 | if (!fingerprint) return false |
| ... | ... | @@ -398,18 +561,92 @@ export function isBuiltinPrinterEnabledByDeviceModel (): boolean { |
| 398 | 561 | /** |
| 399 | 562 | * 一体机(AIO)上若当前连的是 Virtual BT Printer,打印改走内置 ESC 光栅(与预览 Canvas 一致,避免 JSON 直打空白)。 |
| 400 | 563 | */ |
| 401 | -export function preferBuiltinPrinterOnAioDevice (): boolean { | |
| 402 | - if (!isBuiltinPrinterAvailable() || !isBuiltinPrinterEnabledByDeviceModel()) return false | |
| 403 | - if (getPrinterType() === 'builtin') return true | |
| 564 | +export function isAioBuiltinPrintCapable (): boolean { | |
| 565 | + return isBuiltinPrinterAvailable() || isNativeUposPrintSupported() | |
| 566 | +} | |
| 567 | + | |
| 568 | +/** Gprinter D320FAX 一体机(Qualcomm msm8953):UPOS 串口不可用,走佳博 SDK + Virtual BT TSC */ | |
| 569 | +export function isD320faxAioDevice (): boolean { | |
| 570 | + const fp = getDeviceFingerprint().toLowerCase() | |
| 571 | + return fp.includes('d320fax') || fp.includes('msm8953') | |
| 572 | +} | |
| 573 | + | |
| 574 | +/** D320FAX 误切 builtin 后恢复 bluetooth + native-plugin,避免 UPOS 假成功 */ | |
| 575 | +export function restoreD320faxVirtualBtBluetoothMode (): void { | |
| 576 | + if (!isD320faxAioDevice() || !isVirtualBtBluetoothConnection()) return | |
| 577 | + const conn = getBluetoothConnection() | |
| 578 | + if (!conn || getPrinterType() !== 'builtin') return | |
| 579 | + setBluetoothConnection({ | |
| 580 | + deviceId: conn.deviceId, | |
| 581 | + deviceName: conn.deviceName, | |
| 582 | + serviceId: conn.serviceId, | |
| 583 | + characteristicId: conn.characteristicId, | |
| 584 | + deviceType: conn.deviceType, | |
| 585 | + transportMode: conn.transportMode || 'native-plugin', | |
| 586 | + mtu: conn.mtu, | |
| 587 | + driverKey: getCurrentPrinterDriverKey() || 'd320fax', | |
| 588 | + }) | |
| 589 | +} | |
| 590 | + | |
| 591 | +/** 当前打印字节预计走哪条物理通道(供 Printer 页 Debug 展示) */ | |
| 592 | +export function resolvePrintSendChannelHint (): string { | |
| 593 | + const type = getPrinterType() | |
| 594 | + const virtualBt = isVirtualBtBluetoothConnection() | |
| 595 | + const conn = getBluetoothConnection() | |
| 596 | + const uposSupported = isNativeUposPrintSupported() | |
| 597 | + const uposPrefer = getUposOptionsFromStorage().prefer || 'builtin' | |
| 598 | + if (virtualBt) { | |
| 599 | + if (conn?.transportMode === 'native-plugin' && isNativePrintCommandBytesSupported()) { | |
| 600 | + return 'Gprinter SDK TSC (Virtual BT)' | |
| 601 | + } | |
| 602 | + return 'Bluetooth classic TSC (Virtual BT)' | |
| 603 | + } | |
| 604 | + if (type === 'builtin') { | |
| 605 | + if (uposSupported) return `UPOS/${uposPrefer}` | |
| 606 | + if (isBuiltinPrinterAvailable()) return 'TCP localhost' | |
| 607 | + return 'builtin-unavailable' | |
| 608 | + } | |
| 609 | + if (type === 'bluetooth') { | |
| 610 | + if (conn?.deviceType === 'ble') { | |
| 611 | + return 'Bluetooth BLE (Canvas→TSC raster)' | |
| 612 | + } | |
| 613 | + if (conn?.transportMode === 'native-plugin' && isNativePrintCommandBytesSupported()) { | |
| 614 | + return 'Bluetooth printCommandBytes (Gprinter SDK)' | |
| 615 | + } | |
| 616 | + if (conn?.deviceType === 'classic') return 'Bluetooth classic JS' | |
| 617 | + return 'Bluetooth' | |
| 618 | + } | |
| 619 | + return 'none' | |
| 620 | +} | |
| 621 | + | |
| 622 | +/** 当前经典蓝牙是否连到一体机 Virtual BT(虚拟 SPP 别名) */ | |
| 623 | +export function isVirtualBtBluetoothConnection (): boolean { | |
| 404 | 624 | const conn = getBluetoothConnection() |
| 405 | - const btName = String(conn?.deviceName || '').toLowerCase() | |
| 406 | - if (!btName.includes('virtual bt')) return false | |
| 625 | + if (!conn) return false | |
| 626 | + return String(conn.deviceName || '').toLowerCase().includes('virtual bt') | |
| 627 | +} | |
| 628 | + | |
| 629 | +export function preferBuiltinPrinterOnAioDevice (): boolean { | |
| 630 | + restoreD320faxVirtualBtBluetoothMode() | |
| 631 | + | |
| 632 | + if (getPrinterType() === 'builtin') { | |
| 633 | + if (isVirtualBtBluetoothConnection()) return false | |
| 634 | + return true | |
| 635 | + } | |
| 636 | + if (!isAioBuiltinPrintCapable()) return false | |
| 637 | + | |
| 638 | + /** 一体机已连 Virtual BT:保持 Bluetooth + 佳博 TSC,勿切 Built-in/UPOS */ | |
| 639 | + if (isVirtualBtBluetoothConnection()) { | |
| 640 | + return false | |
| 641 | + } | |
| 642 | + | |
| 643 | + if (!isBuiltinPrinterEnabledByDeviceModel()) return false | |
| 407 | 644 | setBuiltinPrinter(getCurrentPrinterDriverKey() || 'generic-tsc') |
| 408 | 645 | return true |
| 409 | 646 | } |
| 410 | 647 | |
| 411 | 648 | export function getAvailablePrinterTypes (): PrinterType[] { |
| 412 | - if (isBuiltinPrinterAvailable() && isBuiltinPrinterEnabledByDeviceModel()) { | |
| 649 | + if (isAioBuiltinPrintCapable() && (isBuiltinPrinterEnabledByDeviceModel() || !!getBluetoothConnection())) { | |
| 413 | 650 | // AIO 机型同时保留蓝牙入口,避免只能走内置打印导致无法连接 Virtual BT Printer。 |
| 414 | 651 | return ['bluetooth', 'builtin'] |
| 415 | 652 | } |
| ... | ... | @@ -451,6 +688,12 @@ export function sendToPrinter ( |
| 451 | 688 | void logPrinterDebug({ reason: 'sendToPrinter', dataBytes: data?.length || 0 }) |
| 452 | 689 | const type = getPrinterType() |
| 453 | 690 | printRunDiag('sendToPrinter', { type, dataBytes: data?.length || 0 }) |
| 691 | + | |
| 692 | + /** Virtual BT:TSC 光栅必须走佳博 SDK / 经典蓝牙 SPP,禁止误投 UPOS(TSC 字节 UPOS 不认会卡死) */ | |
| 693 | + if (isVirtualBtBluetoothConnection()) { | |
| 694 | + return sendViaVirtualBtTsc(data, onProgress) | |
| 695 | + } | |
| 696 | + | |
| 454 | 697 | if (type === 'bluetooth') { |
| 455 | 698 | const conn = getBluetoothConnection() |
| 456 | 699 | if (conn && conn.deviceType === 'classic') { |
| ... | ... | @@ -639,7 +882,10 @@ function sendViaBle ( |
| 639 | 882 | if (!conn) { |
| 640 | 883 | return Promise.reject(new Error('Bluetooth printer not connected.')) |
| 641 | 884 | } |
| 642 | - const { deviceId, serviceId, characteristicId, bleWriteUsesNoResponse } = conn | |
| 885 | + const deviceId = conn.deviceId | |
| 886 | + let serviceId = conn.serviceId | |
| 887 | + let characteristicId = conn.characteristicId | |
| 888 | + let bleWriteUsesNoResponse = conn.bleWriteUsesNoResponse | |
| 643 | 889 | |
| 644 | 890 | const runWritesWithPayloadSize = (payloadSize: number): Promise<void> => { |
| 645 | 891 | const chunks: number[][] = [] |
| ... | ... | @@ -661,11 +907,10 @@ function sendViaBle ( |
| 661 | 907 | ? 2 |
| 662 | 908 | : 10 |
| 663 | 909 | |
| 664 | - /** Nordic 白名单:仅用于失败时是否禁止翻到带响应写(佳博等);写入方式以连接时写入 storage 的 bleWriteUsesNoResponse 为准 */ | |
| 910 | + /** 佳博 Nordic 白名单:无视 storage 里旧的 false,长任务必须 writeNoResponse */ | |
| 665 | 911 | const blePairForceNoResponse = blePairRequiresWriteNoResponse(serviceId, characteristicId) |
| 666 | 912 | |
| 667 | - /** 本 job 内若因 property not support 翻过模式,后续包统一用 effectiveUseNoResp */ | |
| 668 | - let effectiveUseNoResp = bleWriteUsesNoResponse | |
| 913 | + let effectiveUseNoResp = blePairForceNoResponse ? true : bleWriteUsesNoResponse | |
| 669 | 914 | let hasFlippedWriteModeThisJob = false |
| 670 | 915 | let pendingPersistUseNoResp: boolean | null = null |
| 671 | 916 | |
| ... | ... | @@ -691,7 +936,7 @@ function sendViaBle ( |
| 691 | 936 | errCode: err?.errCode, |
| 692 | 937 | errno: err?.errno, |
| 693 | 938 | code: err?.code, |
| 694 | - writeTypeUsed: useNoResp ? 'writeNoResponse' : '(omitted, default write)', | |
| 939 | + writeTypeUsed: useNoResp ? 'writeNoResponse' : 'write', | |
| 695 | 940 | effectiveUseNoResp, |
| 696 | 941 | bleWriteUsesNoResponseSaved: bleWriteUsesNoResponse, |
| 697 | 942 | bufferLen, |
| ... | ... | @@ -715,10 +960,8 @@ function sendViaBle ( |
| 715 | 960 | characteristicId, |
| 716 | 961 | value: buffer, |
| 717 | 962 | } |
| 718 | - /** 仅无响应写显式声明;带响应写不传 writeType,与 uni 历史默认一致,避免部分机型报 property not support */ | |
| 719 | - if (useNoResp) { | |
| 720 | - opts.writeType = 'writeNoResponse' | |
| 721 | - } | |
| 963 | + /** 无响应写 / 带响应写均显式声明 writeType,兼容新版 Android BLE 栈 */ | |
| 964 | + opts.writeType = useNoResp ? 'writeNoResponse' : 'write' | |
| 722 | 965 | uni.writeBLECharacteristicValue({ |
| 723 | 966 | ...opts, |
| 724 | 967 | success: () => { |
| ... | ... | @@ -740,27 +983,37 @@ function sendViaBle ( |
| 740 | 983 | msg.includes('property not support') || |
| 741 | 984 | msg.includes('not support') || |
| 742 | 985 | String(err?.errCode) === '10007' |
| 743 | - if (allowFlip && !hasFlippedWriteModeThisJob && notSupport) { | |
| 986 | + if (notSupport) { | |
| 744 | 987 | const nextUseNoResp = !useNoResp |
| 745 | - /** | |
| 746 | - * 佳博等 Nordic 串口:GATT 虽声明 write,实测只接受 writeNoResponse。 | |
| 747 | - * 若 writeNoResponse 偶发失败后翻到「默认写」,首包可能仍 success,从第二包起必现 10007(打印量变长更易触发)。 | |
| 748 | - */ | |
| 749 | - if (blePairForceNoResponse && !nextUseNoResp) { | |
| 988 | + /** 白名单 mid-job:带响应写跑若干包后 10007 → 立即改 writeNoResponse 重试本包 */ | |
| 989 | + if (blePairForceNoResponse && !useNoResp && nextUseNoResp) { | |
| 990 | + effectiveUseNoResp = true | |
| 991 | + pendingPersistUseNoResp = true | |
| 992 | + console.warn('[sendViaBle] 白名单串口 mid-job write→writeNoResponse 重试', { | |
| 993 | + sentIndex: sent, | |
| 994 | + totalChunks: total, | |
| 995 | + }) | |
| 996 | + tryWrite(true, false) | |
| 997 | + return | |
| 998 | + } | |
| 999 | + /** 白名单已用 writeNoResponse 跑通后,禁止切到带响应写 */ | |
| 1000 | + if (blePairForceNoResponse && !nextUseNoResp && sent > 0) { | |
| 750 | 1001 | console.warn( |
| 751 | - '[sendViaBle] 白名单串口禁止切到带响应写;请保持 writeNoResponse 或检查连接/MTU' | |
| 1002 | + '[sendViaBle] 白名单串口已发包后禁止切到带响应写;请保持 writeNoResponse' | |
| 752 | 1003 | ) |
| 753 | 1004 | reject(new Error(msg || 'BLE write failed')) |
| 754 | 1005 | return |
| 755 | 1006 | } |
| 756 | - hasFlippedWriteModeThisJob = true | |
| 757 | - effectiveUseNoResp = nextUseNoResp | |
| 758 | - pendingPersistUseNoResp = effectiveUseNoResp | |
| 759 | - console.warn('[sendViaBle] property not support → 切换写入方式重试本包', { | |
| 760 | - nextMode: effectiveUseNoResp ? 'writeNoResponse' : 'defaultWrite(no writeType)', | |
| 761 | - }) | |
| 762 | - tryWrite(effectiveUseNoResp, false) | |
| 763 | - return | |
| 1007 | + if (allowFlip && !hasFlippedWriteModeThisJob && sent === 0) { | |
| 1008 | + hasFlippedWriteModeThisJob = true | |
| 1009 | + effectiveUseNoResp = nextUseNoResp | |
| 1010 | + pendingPersistUseNoResp = effectiveUseNoResp | |
| 1011 | + console.warn('[sendViaBle] property not support → 首包切换写入方式重试', { | |
| 1012 | + nextMode: effectiveUseNoResp ? 'writeNoResponse' : 'write', | |
| 1013 | + }) | |
| 1014 | + tryWrite(effectiveUseNoResp, false) | |
| 1015 | + return | |
| 1016 | + } | |
| 764 | 1017 | } |
| 765 | 1018 | reject(new Error(msg || 'BLE write failed')) |
| 766 | 1019 | }, |
| ... | ... | @@ -830,14 +1083,23 @@ function sendViaBle ( |
| 830 | 1083 | |
| 831 | 1084 | // #ifdef APP-PLUS |
| 832 | 1085 | if (conn.deviceType === 'ble') { |
| 833 | - const driver = getPrinterDriverByKey(getCurrentPrinterDriverKey()) | |
| 834 | - const preferred = driver.preferredBleMtu || BLE_MTU_DEFAULT | |
| 1086 | + const driverKey = getCurrentPrinterDriverKey() | |
| 1087 | + const driver = getPrinterDriverByKey(driverKey) | |
| 1088 | + const nordicLikely = isNordicUartStyleBleService(serviceId) | |
| 835 | 1089 | return bleOpenAdapter() |
| 836 | 1090 | .then(() => bleEnsureDeviceConnected(deviceId)) |
| 837 | - .then(() => new Promise<void>((r) => setTimeout(r, 100))) | |
| 838 | - .then(() => ensureBleUartNotifyIfNeeded(deviceId, serviceId, characteristicId)) | |
| 839 | - .then(() => requestBleMtuNegotiation(deviceId, preferred)) | |
| 840 | - .then((negotiated) => runWritesWithPayloadSize(mtuToPayloadSize(negotiated))) | |
| 1091 | + .then(() => new Promise<void>((r) => setTimeout(r, nordicLikely ? 200 : 100))) | |
| 1092 | + .then(() => refreshBlePrintTarget(deviceId, driverKey || driver.key)) | |
| 1093 | + .then(() => { | |
| 1094 | + const latest = getBluetoothConnection() | |
| 1095 | + if (latest) { | |
| 1096 | + serviceId = latest.serviceId | |
| 1097 | + characteristicId = latest.characteristicId | |
| 1098 | + bleWriteUsesNoResponse = latest.bleWriteUsesNoResponse | |
| 1099 | + } | |
| 1100 | + const mtu = latest?.mtu || driver.preferredBleMtu || BLE_MTU_DEFAULT | |
| 1101 | + return runWritesWithPayloadSize(mtuToPayloadSize(mtu)) | |
| 1102 | + }) | |
| 841 | 1103 | } |
| 842 | 1104 | // #endif |
| 843 | 1105 | return runWritesWithPayloadSize(mtuToPayloadSize(conn.mtu || BLE_MTU_DEFAULT)) |
| ... | ... | @@ -867,11 +1129,58 @@ function numberArrayToBase64 (data: number[]): string { |
| 867 | 1129 | } |
| 868 | 1130 | |
| 869 | 1131 | /** |
| 1132 | + * Virtual BT(含 D320FAX / AOA 一体机):Canvas 光栅 TSC 指令 → 佳博 SDK printCommandBytes 或经典蓝牙 SPP。 | |
| 1133 | + */ | |
| 1134 | +async function sendViaVirtualBtTsc ( | |
| 1135 | + data: number[], | |
| 1136 | + onProgress?: (percent: number) => void, | |
| 1137 | +): Promise<void> { | |
| 1138 | + printRunDiag('sendToPrinter_virtual_bt_tsc', { dataBytes: data?.length || 0 }) | |
| 1139 | + await ensureNativeClassicTransportIfPossible() | |
| 1140 | + const conn = getBluetoothConnection() | |
| 1141 | + if (conn?.transportMode === 'native-plugin' && isNativePrintCommandBytesSupported()) { | |
| 1142 | + const nativeWaitMs = Math.min( | |
| 1143 | + 600000, | |
| 1144 | + Math.max(60000, estimateClassicSendTimeoutMs(data.length)), | |
| 1145 | + ) | |
| 1146 | + try { | |
| 1147 | + await sendViaNativeClassicPlugin(data, onProgress, { writeTimeoutMs: nativeWaitMs }) | |
| 1148 | + return | |
| 1149 | + } catch (e) { | |
| 1150 | + printRunDiag('virtual_bt_native_bytes_fallback_js', { | |
| 1151 | + err: String((e as Error)?.message || e), | |
| 1152 | + dataBytes: data.length, | |
| 1153 | + }) | |
| 1154 | + try { | |
| 1155 | + if (classicBluetooth?.ensureConnection) { | |
| 1156 | + classicBluetooth.ensureConnection(conn.deviceId) | |
| 1157 | + } | |
| 1158 | + } catch (_) {} | |
| 1159 | + return sendViaClassic(data, onProgress) | |
| 1160 | + } | |
| 1161 | + } | |
| 1162 | + if (conn?.deviceType === 'classic') { | |
| 1163 | + printRunDiag('sendToPrinter_virtual_bt_classic_js', { dataBytes: data?.length || 0 }) | |
| 1164 | + return sendViaClassic(data, onProgress) | |
| 1165 | + } | |
| 1166 | + return Promise.reject(new Error('Virtual BT printer not connected.')) | |
| 1167 | +} | |
| 1168 | + | |
| 1169 | +/** @deprecated 使用 sendViaVirtualBtTsc;保留别名兼容旧诊断关键字 */ | |
| 1170 | +async function sendViaD320faxVirtualBt ( | |
| 1171 | + data: number[], | |
| 1172 | + onProgress?: (percent: number) => void, | |
| 1173 | +): Promise<void> { | |
| 1174 | + return sendViaVirtualBtTsc(data, onProgress) | |
| 1175 | +} | |
| 1176 | + | |
| 1177 | +/** | |
| 870 | 1178 | * 经典蓝牙已走 native-fast-printer 佳博 SDK 时,整页光栅字节经 printCommandBytes 下发,避免 JS 蓝牙慢发。 |
| 871 | 1179 | */ |
| 872 | 1180 | function sendViaNativeClassicPlugin ( |
| 873 | 1181 | data: number[], |
| 874 | - onProgress?: (percent: number) => void | |
| 1182 | + onProgress?: (percent: number) => void, | |
| 1183 | + options: { writeTimeoutMs?: number } = {}, | |
| 875 | 1184 | ): Promise<void> { |
| 876 | 1185 | const conn = getBluetoothConnection() |
| 877 | 1186 | if (!conn || conn.deviceType !== 'classic' || conn.transportMode !== 'native-plugin') { |
| ... | ... | @@ -884,13 +1193,40 @@ function sendViaNativeClassicPlugin ( |
| 884 | 1193 | if (!base64) { |
| 885 | 1194 | return Promise.reject(new Error('BASE64_ENCODE_FAILED')) |
| 886 | 1195 | } |
| 1196 | + const writeTimeoutMs = options.writeTimeoutMs | |
| 1197 | + ?? Math.min(600000, Math.max(45000, estimateClassicSendTimeoutMs(data.length))) | |
| 887 | 1198 | if (onProgress) onProgress(5) |
| 1199 | + let waitVisual = 5 | |
| 1200 | + const waitKeepAlive = onProgress | |
| 1201 | + ? setInterval(() => { | |
| 1202 | + if (waitVisual >= 95) return | |
| 1203 | + waitVisual = Math.min(95, waitVisual + 1) | |
| 1204 | + onProgress(waitVisual) | |
| 1205 | + }, 1200) | |
| 1206 | + : null | |
| 888 | 1207 | return printNativeCommandBytes({ |
| 889 | 1208 | deviceId: conn.deviceId, |
| 890 | 1209 | deviceName: conn.deviceName, |
| 891 | 1210 | base64, |
| 892 | - }).then(() => { | |
| 893 | - if (onProgress) onProgress(100) | |
| 1211 | + }).then(async () => { | |
| 1212 | + printRunDiag('native_printCommandBytes_queued', { dataBytes: data.length }) | |
| 1213 | + try { | |
| 1214 | + const verified = await waitForNativeCommandBytesWriteComplete(writeTimeoutMs, (waitPct) => { | |
| 1215 | + if (onProgress) { | |
| 1216 | + const p = Math.max(waitVisual, 8 + Math.round(waitPct * 0.9)) | |
| 1217 | + waitVisual = p | |
| 1218 | + onProgress(p) | |
| 1219 | + } | |
| 1220 | + }) | |
| 1221 | + printRunDiag('native_printCommandBytes_verified', { | |
| 1222 | + writeMs: verified.writeMs, | |
| 1223 | + commandBytes: verified.commandBytes, | |
| 1224 | + stage: verified.stage, | |
| 1225 | + }) | |
| 1226 | + if (onProgress) onProgress(100) | |
| 1227 | + } finally { | |
| 1228 | + if (waitKeepAlive) clearInterval(waitKeepAlive) | |
| 1229 | + } | |
| 894 | 1230 | }) |
| 895 | 1231 | } |
| 896 | 1232 | |
| ... | ... | @@ -1071,6 +1407,7 @@ function sendViaBuiltin (data: number[]): Promise<void> { |
| 1071 | 1407 | console.log('[builtin] connected on port ' + port) |
| 1072 | 1408 | uni.setStorageSync(STORAGE_BUILTIN_PORT, String(port)) |
| 1073 | 1409 | moeTcp.sendHexStr({ message: hexStr }) |
| 1410 | + printRunDiag('builtin_tcp_sent', { port, dataBytes: data.length }) | |
| 1074 | 1411 | setTimeout(() => { |
| 1075 | 1412 | try { moeTcp.disconnect() } catch (_) {} |
| 1076 | 1413 | resolve() |
| ... | ... | @@ -1088,11 +1425,20 @@ function sendViaBuiltin (data: number[]): Promise<void> { |
| 1088 | 1425 | // rk3568 类一体机:优先走 UnifiedPOS 内置/串口直打(无需蓝牙配对、无需 localhost 端口服务) |
| 1089 | 1426 | const fingerprint = getDeviceFingerprint() |
| 1090 | 1427 | const uposOptions = getUposOptionsFromStorage() |
| 1428 | + const isD320faxDevice = isD320faxAioDevice() | |
| 1091 | 1429 | const matchesKeywords = !!fingerprint |
| 1092 | 1430 | && (fingerprint.includes('rk3568') || fingerprint.includes('aoa_rk3568')) |
| 1431 | + const virtualBtLinked = isVirtualBtBluetoothConnection() | |
| 1093 | 1432 | const uposSupported = isNativeUposPrintSupported() |
| 1094 | - const shouldTryUpos = uposSupported && (uposOptions.force || matchesKeywords) | |
| 1095 | - const shouldPreferTcpFirst = matchesKeywords && !uposOptions.force && uposOptions.prefer !== 'serial' | |
| 1433 | + const shouldTryUpos = uposSupported && ( | |
| 1434 | + uposOptions.force | |
| 1435 | + || (matchesKeywords && !isD320faxDevice) | |
| 1436 | + || (getPrinterType() === 'builtin' && !isD320faxDevice && !virtualBtLinked) | |
| 1437 | + ) | |
| 1438 | + const shouldPreferTcpFirst = (matchesKeywords || isD320faxDevice) | |
| 1439 | + && !uposOptions.force | |
| 1440 | + && uposOptions.prefer !== 'serial' | |
| 1441 | + && !virtualBtLinked | |
| 1096 | 1442 | console.warn( |
| 1097 | 1443 | '[builtin] route decision' |
| 1098 | 1444 | + ' fingerprint=' + String(fingerprint || '-') |
| ... | ... | @@ -1173,9 +1519,25 @@ function sendViaBuiltin (data: number[]): Promise<void> { |
| 1173 | 1519 | } |
| 1174 | 1520 | }) |
| 1175 | 1521 | |
| 1176 | - return uposJob.catch((e: any) => { | |
| 1522 | + return uposJob.catch(async (e: any) => { | |
| 1177 | 1523 | const msg = e instanceof Error ? e.message : String(e || 'UPOS_PRINT_FAILED') |
| 1178 | - printRunDiag('builtin_upos_fail_fallback_tcp', { err: msg.slice(0, 240), waitMs: Date.now() - tUpos }) | |
| 1524 | + printRunDiag('builtin_upos_fail_fallback', { err: msg.slice(0, 240), waitMs: Date.now() - tUpos }) | |
| 1525 | + | |
| 1526 | + if (isVirtualBtBluetoothConnection()) { | |
| 1527 | + try { | |
| 1528 | + await sendViaVirtualBtTsc(data, onProgress) | |
| 1529 | + return | |
| 1530 | + } catch (gErr: any) { | |
| 1531 | + const gMsg = gErr instanceof Error ? gErr.message : String(gErr || 'VIRTUAL_BT_TSC_FAILED') | |
| 1532 | + throw new Error(`UPOS failed (${msg}); Virtual BT TSC failed (${gMsg})`) | |
| 1533 | + } | |
| 1534 | + } | |
| 1535 | + | |
| 1536 | + if (isD320faxAioDevice()) { | |
| 1537 | + console.warn('[builtin] D320FAX UPOS failed, fallback localhost TCP', msg) | |
| 1538 | + return sendViaBuiltinTcpLocalhost() | |
| 1539 | + } | |
| 1540 | + | |
| 1179 | 1541 | console.warn('[builtin] UPOS failed/timeout, fallback localhost TCP', msg) |
| 1180 | 1542 | return sendViaBuiltinTcpLocalhost() |
| 1181 | 1543 | }) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
| ... | ... | @@ -6,6 +6,11 @@ import { |
| 6 | 6 | shouldRasterizeTextElement, |
| 7 | 7 | } from './nativeBitmapPatch' |
| 8 | 8 | import { applyTemplateData } from './templateRenderer' |
| 9 | +import { | |
| 10 | + isPrintInputLiteralField, | |
| 11 | + resolvePrintInputLiteralDisplay, | |
| 12 | +} from '../labelPreview/printInputOffset' | |
| 13 | +import { readElementBorder } from '../textElementLayout' | |
| 9 | 14 | import type { |
| 10 | 15 | EscTemplateItem, |
| 11 | 16 | LabelTemplateData, |
| ... | ... | @@ -37,6 +42,10 @@ function templateWidthPx (template: SystemLabelTemplate): number { |
| 37 | 42 | return toMillimeter(template.width, template.unit || 'inch') / 25.4 * DESIGN_DPI |
| 38 | 43 | } |
| 39 | 44 | |
| 45 | +function templateHeightPx (template: SystemLabelTemplate): number { | |
| 46 | + return toMillimeter(template.height, template.unit || 'inch') / 25.4 * DESIGN_DPI | |
| 47 | +} | |
| 48 | + | |
| 40 | 49 | function pxToDots (value: number, dpi: number): number { |
| 41 | 50 | return Math.max(0, Math.round((Number(value) || 0) * dpi / DESIGN_DPI)) |
| 42 | 51 | } |
| ... | ... | @@ -167,6 +176,9 @@ function resolveElementText ( |
| 167 | 176 | element: SystemTemplateElementBase, |
| 168 | 177 | data: LabelTemplateData |
| 169 | 178 | ): string { |
| 179 | + if (isPrintInputLiteralField(element)) { | |
| 180 | + return resolvePrintInputLiteralDisplay(element) | |
| 181 | + } | |
| 170 | 182 | const config = element.config || {} |
| 171 | 183 | const type = String(element.type || '').toUpperCase() |
| 172 | 184 | const hasText = config.text != null && config.text !== '' |
| ... | ... | @@ -249,7 +261,11 @@ function toEscAlign (align: SystemTemplateTextAlign): 0 | 1 | 2 { |
| 249 | 261 | } |
| 250 | 262 | |
| 251 | 263 | function resolveRotation (value?: string): number { |
| 252 | - return value === 'vertical' ? 90 : 0 | |
| 264 | + const rotation = String(value || 'horizontal').trim().toLowerCase() | |
| 265 | + if (rotation === 'vertical' || rotation === 'left' || rotation === 'rotate-left' || rotation === 'left90') return 90 | |
| 266 | + if (rotation === 'right' || rotation === 'rotate-right' || rotation === 'right90') return 270 | |
| 267 | + if (rotation === 'rotate180' || rotation === '180' || rotation === 'down' || rotation === 'inverted') return 180 | |
| 268 | + return 0 | |
| 253 | 269 | } |
| 254 | 270 | |
| 255 | 271 | function normalizeQrLevel (value?: string): 'L' | 'M' | 'Q' | 'H' { |
| ... | ... | @@ -334,14 +350,30 @@ function sanitizeTextForTscBuiltinFont (text: string): string { |
| 334 | 350 | } |
| 335 | 351 | |
| 336 | 352 | /** 估算 TSC 项底部点坐标,用于避免 SIZE 高度略小于内容时裁掉最后一行(常见于底部价格) */ |
| 353 | +/** 整标签纸外框(配置区 Border,与元素 border 不同) */ | |
| 354 | +function pushTemplatePaperBorderIfNeeded ( | |
| 355 | + items: TscTemplateItem[], | |
| 356 | + template: SystemLabelTemplate, | |
| 357 | + dpi: number | |
| 358 | +) { | |
| 359 | + const border = String(template.border || '').toLowerCase() | |
| 360 | + if (border !== 'line' && border !== 'dotted') return | |
| 361 | + items.push({ | |
| 362 | + type: 'box', | |
| 363 | + x: 0, | |
| 364 | + y: 0, | |
| 365 | + width: Math.max(1, pxToDots(templateWidthPx(template), dpi)), | |
| 366 | + height: Math.max(1, pxToDots(templateHeightPx(template), dpi)), | |
| 367 | + lineWidth: border === 'dotted' ? 2 : 3, | |
| 368 | + }) | |
| 369 | +} | |
| 370 | + | |
| 337 | 371 | function pushElementBorderBoxIfNeeded ( |
| 338 | 372 | items: TscTemplateItem[], |
| 339 | 373 | element: SystemTemplateElementBase, |
| 340 | 374 | dpi: number |
| 341 | 375 | ) { |
| 342 | - const type = String(element.type || '').toUpperCase() | |
| 343 | - if (type === 'BLANK') return | |
| 344 | - const border = String(element.border || '').toLowerCase() | |
| 376 | + const border = readElementBorder(element) | |
| 345 | 377 | if (border !== 'line' && border !== 'dotted') return |
| 346 | 378 | items.push({ |
| 347 | 379 | type: 'box', |
| ... | ... | @@ -391,6 +423,7 @@ function buildTscTemplate ( |
| 391 | 423 | const items: TscTemplateItem[] = [] |
| 392 | 424 | |
| 393 | 425 | const pageWidth = templateWidthPx(template) |
| 426 | + pushTemplatePaperBorderIfNeeded(items, template, dpi) | |
| 394 | 427 | |
| 395 | 428 | sortElements(template.elements).forEach((element) => { |
| 396 | 429 | const config = element.config || {} |
| ... | ... | @@ -421,7 +454,7 @@ function buildTscTemplate ( |
| 421 | 454 | options.allowCurrencyBitmapWhenDisabled !== false && |
| 422 | 455 | (currencyGlyph || type === 'TEXT_PRICE') |
| 423 | 456 | const tryTextBitmap = |
| 424 | - shouldRasterizeTextElement(text, type) && | |
| 457 | + shouldRasterizeTextElement(text, type, config) && | |
| 425 | 458 | (!options.disableBitmapText || allowBitmapWhenDisabled) |
| 426 | 459 | if (tryTextBitmap) { |
| 427 | 460 | const bitmapPatch = createTextBitmapPatch({ |
| ... | ... | @@ -527,16 +560,6 @@ function buildTscTemplate ( |
| 527 | 560 | if (bitmapPatch) items.push(bitmapPatch) |
| 528 | 561 | return |
| 529 | 562 | } |
| 530 | - | |
| 531 | - if (type === 'BLANK' && String(element.border || '').toLowerCase() === 'line') { | |
| 532 | - items.push({ | |
| 533 | - type: 'bar', | |
| 534 | - x: pxToDots(element.x, dpi), | |
| 535 | - y: pxToDots(element.y, dpi), | |
| 536 | - width: Math.max(1, pxToDots(element.width, dpi)), | |
| 537 | - height: Math.max(1, pxToDots(element.height || 1, dpi)), | |
| 538 | - }) | |
| 539 | - } | |
| 540 | 563 | }) |
| 541 | 564 | |
| 542 | 565 | const maxBottomDots = items.reduce( |
| ... | ... | @@ -627,7 +650,7 @@ function buildEscTemplate ( |
| 627 | 650 | return |
| 628 | 651 | } |
| 629 | 652 | |
| 630 | - if (type === 'BLANK' && String(element.border || '').toLowerCase() === 'line') { | |
| 653 | + if (type === 'BLANK' && readElementBorder(element) === 'line') { | |
| 631 | 654 | items.push({ |
| 632 | 655 | type: 'rule', |
| 633 | 656 | width: clamp(element.width / 8, 8, 48), | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/templatePhysicalMm.ts
| ... | ... | @@ -125,6 +125,43 @@ export function templateSizeToMillimeters ( |
| 125 | 125 | return { widthMm, heightMm } |
| 126 | 126 | } |
| 127 | 127 | |
| 128 | +/** 设计画布像素(96dpi 坐标系)→ 物理毫米,与 Web unitToPx / App toCanvasPx 一致 */ | |
| 129 | +export function designCanvasPxToMillimeters ( | |
| 130 | + cw: number, | |
| 131 | + ch: number, | |
| 132 | +): { widthMm: number; heightMm: number } { | |
| 133 | + return { | |
| 134 | + widthMm: Math.round(((cw / DESIGN_DPI) * 25.4) * 10) / 10, | |
| 135 | + heightMm: Math.round(((ch / DESIGN_DPI) * 25.4) * 10) / 10, | |
| 136 | + } | |
| 137 | +} | |
| 138 | + | |
| 139 | +/** 光栅出纸像素(printDpi)→ 物理毫米,SIZE 与位图一致,各机型比例统一 */ | |
| 140 | +export function rasterDotsToMillimeters ( | |
| 141 | + widthDots: number, | |
| 142 | + heightDots: number, | |
| 143 | + printDpi: number, | |
| 144 | +): { widthMm: number; heightMm: number } { | |
| 145 | + const dpi = Math.max(1, printDpi) | |
| 146 | + return { | |
| 147 | + widthMm: Math.round(((widthDots / dpi) * 25.4) * 10) / 10, | |
| 148 | + heightMm: Math.round(((heightDots / dpi) * 25.4) * 10) / 10, | |
| 149 | + } | |
| 150 | +} | |
| 151 | + | |
| 152 | +/** | |
| 153 | + * 预览 / 打印共用设计画布尺寸(与后台 LabelCanvas unitToPx 同一坐标系)。 | |
| 154 | + * 默认用模板根 height,与后台预览一致;不再对 ESC 单独裁 content 高度。 | |
| 155 | + */ | |
| 156 | +export function resolveLabelDesignCanvasPx ( | |
| 157 | + template: Pick<SystemLabelTemplate, 'unit' | 'width' | 'height'>, | |
| 158 | +): { cw: number; ch: number } { | |
| 159 | + const unit = String(template.unit || 'inch') | |
| 160 | + const cw = Math.max(40, Math.round(toCanvasPx(Number(template.width) || 2, unit))) | |
| 161 | + const ch = Math.max(40, Math.round(toCanvasPx(Number(template.height) || 2, unit))) | |
| 162 | + return { cw, ch } | |
| 163 | +} | |
| 164 | + | |
| 128 | 165 | export function isTemplateWithinNativeFastPrintBounds ( |
| 129 | 166 | template: Pick<SystemLabelTemplate, 'unit' | 'width' | 'height'> |
| 130 | 167 | ): boolean { | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/tscLabelBuilder.ts
| ... | ... | @@ -262,8 +262,8 @@ export function buildTscImageLabel ( |
| 262 | 262 | add(`SIZE ${roundMm(widthMm)} mm,${roundMm(heightMm)} mm`) |
| 263 | 263 | add('GAP 0 mm,0 mm') |
| 264 | 264 | add('CODEPAGE 1252') |
| 265 | - add('DENSITY 14') | |
| 266 | - add('SPEED 5') | |
| 265 | + add(`DENSITY ${Math.max(0, Math.min(15, Math.round(options.density ?? 14)))}`) | |
| 266 | + add(`SPEED ${Math.max(1, Math.min(10, Math.round(options.printSpeed ?? 5)))}`) | |
| 267 | 267 | add('CLS') |
| 268 | 268 | addCommandBytes(out, `BITMAP ${x},${y},${bytesPerRow},${image.height},0,`) |
| 269 | 269 | for (let i = 0; i < bitmapBytes.length; i++) out.push(bitmapBytes[i]) | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/print/types/printer.ts
| ... | ... | @@ -23,6 +23,12 @@ export interface LabelPrintPayload { |
| 23 | 23 | export interface PrintImageOptions { |
| 24 | 24 | printQty?: number |
| 25 | 25 | threshold?: number |
| 26 | + /** TSC 打印浓度 0–15;含黑底白字时略降可减轻热敏渗墨 */ | |
| 27 | + density?: number | |
| 28 | + /** TSC 打印速度;略降可减轻黑底内白字糊化 */ | |
| 29 | + printSpeed?: number | |
| 30 | + /** 光栅后对白字区域做 1px 膨胀,减轻热敏黑底渗墨导致白字发糊 */ | |
| 31 | + sharpenInvertedText?: boolean | |
| 26 | 32 | maxWidthDots?: number |
| 27 | 33 | targetWidthDots?: number |
| 28 | 34 | targetHeightDots?: number |
| ... | ... | @@ -62,7 +68,8 @@ export interface RawImageDataSource { |
| 62 | 68 | export type LabelTemplateValue = string | number |
| 63 | 69 | export type LabelTemplateData = Record<string, LabelTemplateValue> |
| 64 | 70 | export type PrinterTemplateUnit = 'inch' | 'mm' | 'cm' | 'px' |
| 65 | -export type SystemTemplateRotation = 'horizontal' | 'vertical' | |
| 71 | +export type SystemTemplateRotation = 'horizontal' | 'vertical' | 'left' | 'right' | 'rotate180' | |
| 72 | +export type SystemTemplatePrintOrientation = 'horizontal' | 'vertical' | |
| 66 | 73 | export type SystemTemplateTextAlign = 'left' | 'center' | 'right' |
| 67 | 74 | |
| 68 | 75 | export interface SystemTemplateElementBase { |
| ... | ... | @@ -91,6 +98,10 @@ export interface SystemLabelTemplate { |
| 91 | 98 | appliedLocation?: string |
| 92 | 99 | showRuler?: boolean |
| 93 | 100 | showGrid?: boolean |
| 101 | + /** 整标签外框:none / line / dotted */ | |
| 102 | + border?: string | |
| 103 | + /** vertical=竖打(默认);horizontal=横打(内容整体旋转 90°,纸张 W×H 不变) */ | |
| 104 | + printOrientation?: SystemTemplatePrintOrientation | |
| 94 | 105 | elements: SystemTemplateElementBase[] |
| 95 | 106 | } |
| 96 | 107 | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts
| ... | ... | @@ -4,7 +4,14 @@ import { |
| 4 | 4 | sortElementsForPreview, |
| 5 | 5 | } from './labelPreview/normalizePreviewTemplate' |
| 6 | 6 | import { |
| 7 | + isCorruptedDateDisplayFormat, | |
| 8 | + isWeekdayBarElement, | |
| 9 | + parseStoredPrintInputOffset, | |
| 10 | + resolveWeekdayDisplayForElement, | |
| 11 | +} from './labelPreview/printInputOffset' | |
| 12 | +import { | |
| 7 | 13 | canPrintCurrentLabelViaNativeFastJob, |
| 14 | + getCurrentPrinterSummary, | |
| 8 | 15 | printLabelPrintJobPayloadForCurrentPrinter, |
| 9 | 16 | printSystemTemplateForCurrentPrinter, |
| 10 | 17 | type SystemTemplatePrintCanvasRasterOptions, |
| ... | ... | @@ -14,14 +21,17 @@ import { |
| 14 | 21 | setLastLabelPrintJobPayload, |
| 15 | 22 | } from './labelPreview/buildLabelPrintPayload' |
| 16 | 23 | import { getCurrentStoreId } from './stores' |
| 17 | -import { ensureNativeClassicTransportIfPossible } from './print/printerConnection' | |
| 24 | +import { ensureNativeClassicTransportIfPossible, getBluetoothConnection, preferBuiltinPrinterOnAioDevice } from './print/printerConnection' | |
| 25 | +import { isVirtualBtPrinterDeviceName } from './print/bluetoothPrinterAllowlist' | |
| 18 | 26 | import { |
| 19 | 27 | hydrateSystemTemplateImagesForPrint, |
| 20 | 28 | resetHydrateImageDebugRecords, |
| 21 | 29 | } from './print/hydrateTemplateImagesForPrint' |
| 22 | 30 | import { |
| 23 | 31 | normalizeTemplateForNativeFastJob, |
| 32 | + templateHasDateTimeElements, | |
| 24 | 33 | templateHasUnsupportedNativeFastElements, |
| 34 | + templateRequiresCanvasStyleFidelity, | |
| 25 | 35 | } from './print/nativeTemplateElementSupport' |
| 26 | 36 | import { |
| 27 | 37 | ensureTemplateHeightCoversElements, |
| ... | ... | @@ -60,11 +70,35 @@ function formatPriceLineForBake (config: Record<string, any>, rawText: string): |
| 60 | 70 | return `${prefix}${value}${suffix}` |
| 61 | 71 | } |
| 62 | 72 | |
| 73 | +function parseReprintBaseTime (row: PrintLogItemDto): Date { | |
| 74 | + const raw = String(row.printedAt ?? '').trim() | |
| 75 | + if (raw) { | |
| 76 | + const d = new Date(raw) | |
| 77 | + if (!Number.isNaN(d.getTime())) return d | |
| 78 | + } | |
| 79 | + return new Date() | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** 重打快照里已算好的展示文案(优先 __previewFormatted,其次 config.text) */ | |
| 83 | +function readReprintSnapshotLine (cfg: Record<string, any>): string { | |
| 84 | + for (const key of ['__previewFormatted', '__PreviewFormatted', 'text', 'Text'] as const) { | |
| 85 | + const raw = String(cfg[key] ?? '').trim() | |
| 86 | + if (!raw) continue | |
| 87 | + if (parseStoredPrintInputOffset(raw)) continue | |
| 88 | + if (isCorruptedDateDisplayFormat(raw)) continue | |
| 89 | + return raw | |
| 90 | + } | |
| 91 | + return '' | |
| 92 | +} | |
| 93 | + | |
| 63 | 94 | /** |
| 64 | 95 | * 原生快打插件对 TEXT_PRODUCT / TEXT_PRICE / DATE 等仍会按类型做绑定;重打时 dataJson 为空, |
| 65 | 96 | * 必须把接口/落库快照里已填好的展示值焙成 TEXT_STATIC,否则会出现第二行商品名、价格 0、日期成 format 等问题。 |
| 66 | 97 | */ |
| 67 | -export function bakeReprintTemplateSnapshot (tmpl: SystemLabelTemplate): SystemLabelTemplate { | |
| 98 | +export function bakeReprintTemplateSnapshot ( | |
| 99 | + tmpl: SystemLabelTemplate, | |
| 100 | + reprintBaseTime: Date = new Date(), | |
| 101 | +): SystemLabelTemplate { | |
| 68 | 102 | const elements = (tmpl.elements || []).map((el) => { |
| 69 | 103 | const vst = String(el.valueSourceType || '').toUpperCase() |
| 70 | 104 | const type = String(el.type || '').toUpperCase() |
| ... | ... | @@ -74,9 +108,47 @@ export function bakeReprintTemplateSnapshot (tmpl: SystemLabelTemplate): SystemL |
| 74 | 108 | ...el, |
| 75 | 109 | type: 'TEXT_STATIC', |
| 76 | 110 | valueSourceType: 'FIXED', |
| 77 | - config: { ...cfg, text: line, Text: line }, | |
| 111 | + config: { | |
| 112 | + ...cfg, | |
| 113 | + text: line, | |
| 114 | + Text: line, | |
| 115 | + __previewFormatted: line, | |
| 116 | + __PreviewFormatted: line, | |
| 117 | + }, | |
| 78 | 118 | }) |
| 79 | 119 | |
| 120 | + /** 黑条星期:快照有 FRIDAY 等则冻结;仅有偏移 JSON 时按原打印时刻 + 偏移还原 */ | |
| 121 | + if (isWeekdayBarElement({ ...el, config: cfg })) { | |
| 122 | + let line = readReprintSnapshotLine(cfg) | |
| 123 | + if (!line) { | |
| 124 | + try { | |
| 125 | + const live = resolveWeekdayDisplayForElement({ ...el, config: cfg }, reprintBaseTime) | |
| 126 | + if (live && !isCorruptedDateDisplayFormat(live)) line = live | |
| 127 | + } catch { | |
| 128 | + /* ignore */ | |
| 129 | + } | |
| 130 | + } | |
| 131 | + if (line) return toStatic(line) | |
| 132 | + } | |
| 133 | + | |
| 134 | + /** 快照里 DATE/TIME/DURATION 的 config.text 已是当时出纸值;重打禁止再按 now 重算 */ | |
| 135 | + if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { | |
| 136 | + const line = readReprintSnapshotLine(cfg) | |
| 137 | + if (line) return toStatic(line) | |
| 138 | + } | |
| 139 | + | |
| 140 | + if (type === 'WEIGHT') { | |
| 141 | + const line = | |
| 142 | + readReprintSnapshotLine(cfg) | |
| 143 | + || (() => { | |
| 144 | + const v = String(cfg.value ?? cfg.Value ?? '').trim() | |
| 145 | + const u = String(cfg.unit ?? cfg.Unit ?? '').trim() | |
| 146 | + if (v && u) return v.endsWith(u) ? v : `${v}${u}` | |
| 147 | + return v || u | |
| 148 | + })() | |
| 149 | + if (line) return toStatic(line) | |
| 150 | + } | |
| 151 | + | |
| 80 | 152 | if (vst === 'FIXED') { |
| 81 | 153 | if (type === 'TEXT_PRODUCT' || type === 'TEXT_CATEGORY' || type === 'TEXT_LABEL_ID') { |
| 82 | 154 | const t = String(cfg.text ?? cfg.Text ?? '').trim() |
| ... | ... | @@ -97,10 +169,6 @@ export function bakeReprintTemplateSnapshot (tmpl: SystemLabelTemplate): SystemL |
| 97 | 169 | } |
| 98 | 170 | |
| 99 | 171 | if (vst === 'PRINT_INPUT') { |
| 100 | - if (type === 'DATE' || type === 'TIME' || type === 'DURATION') { | |
| 101 | - const textVal = String(cfg.text ?? cfg.Text ?? '').trim() | |
| 102 | - if (textVal) return toStatic(textVal) | |
| 103 | - } | |
| 104 | 172 | if (type === 'WEIGHT') { |
| 105 | 173 | const textVal = String(cfg.text ?? cfg.Text ?? '').trim() |
| 106 | 174 | const v = String(cfg.value ?? cfg.Value ?? '').trim() |
| ... | ... | @@ -149,7 +217,8 @@ export function bakeReprintTemplateSnapshot (tmpl: SystemLabelTemplate): SystemL |
| 149 | 217 | * normalizeLabelTemplateFromPreviewApi 只读 e.config,会得到空 config,打印全空。 |
| 150 | 218 | */ |
| 151 | 219 | function inferElementTypeFromPrintSnapshotConfig (cfg: Record<string, unknown>): string { |
| 152 | - const inputType = String(cfg.inputType ?? '').toLowerCase() | |
| 220 | + const inputType = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase() | |
| 221 | + if (inputType === 'number' || inputType === 'text') return 'TEXT_STATIC' | |
| 153 | 222 | const hasUnit = cfg.unit != null || cfg.Unit != null |
| 154 | 223 | const hasValue = cfg.value != null || cfg.Value != null |
| 155 | 224 | if (hasUnit && hasValue) return 'WEIGHT' |
| ... | ... | @@ -237,6 +306,25 @@ function elementFromPrintDataItem ( |
| 237 | 306 | base.id = item.elementId |
| 238 | 307 | base.Id = item.elementId |
| 239 | 308 | } |
| 309 | + const cfgRaw = base.config ?? base.Config ?? base.ConfigJson ?? base.configJson | |
| 310 | + let cfg: Record<string, unknown> | |
| 311 | + if (cfgRaw != null && typeof cfgRaw === 'object' && !Array.isArray(cfgRaw)) { | |
| 312 | + cfg = { ...(cfgRaw as Record<string, unknown>) } | |
| 313 | + } else if (typeof cfgRaw === 'string' && cfgRaw.trim()) { | |
| 314 | + try { | |
| 315 | + cfg = JSON.parse(cfgRaw) as Record<string, unknown> | |
| 316 | + } catch { | |
| 317 | + cfg = {} | |
| 318 | + } | |
| 319 | + } else { | |
| 320 | + cfg = {} | |
| 321 | + } | |
| 322 | + const elType = String( | |
| 323 | + base.type ?? base.Type ?? base.elementType ?? base.ElementType ?? inferElementTypeFromPrintSnapshotConfig(cfg), | |
| 324 | + ) | |
| 325 | + applyRenderValueToSnapshotConfig(cfg, elType, item.renderValue) | |
| 326 | + base.config = cfg | |
| 327 | + base.Config = cfg | |
| 240 | 328 | return base |
| 241 | 329 | } |
| 242 | 330 | |
| ... | ... | @@ -356,12 +444,20 @@ async function printReprintTemplateWithPreviewStrategy ( |
| 356 | 444 | options: PrintFromPrintLogOptions, |
| 357 | 445 | ): Promise<void> { |
| 358 | 446 | await ensureNativeClassicTransportIfPossible() |
| 447 | + preferBuiltinPrinterOnAioDevice() | |
| 359 | 448 | const templateData = labelTemplateDataForSnapshotReprint() |
| 360 | 449 | const printInputJson: Record<string, unknown> = {} |
| 361 | 450 | const tmplSized = ensureTemplateHeightCoversElements(tmpl) |
| 362 | 451 | const tmplForNative = normalizeTemplateForNativeFastJob(tmplSized, printInputJson as any) |
| 452 | + const summary = getCurrentPrinterSummary() | |
| 453 | + const conn = getBluetoothConnection() | |
| 454 | + const mustUseCanvasRaster = | |
| 455 | + summary.type === 'builtin' | |
| 456 | + || templateRequiresCanvasStyleFidelity(tmplSized) | |
| 457 | + || templateHasDateTimeElements(tmplSized) | |
| 363 | 458 | const useNative = |
| 364 | - canPrintCurrentLabelViaNativeFastJob() | |
| 459 | + !mustUseCanvasRaster | |
| 460 | + && canPrintCurrentLabelViaNativeFastJob() | |
| 365 | 461 | && isTemplateWithinNativeFastPrintBounds(tmplSized) |
| 366 | 462 | && !templateHasUnsupportedNativeFastElements(tmplForNative) |
| 367 | 463 | |
| ... | ... | @@ -439,8 +535,9 @@ export async function printFromPrintDataListRow ( |
| 439 | 535 | } |
| 440 | 536 | |
| 441 | 537 | const templateData = labelTemplateDataForSnapshotReprint() |
| 538 | + const reprintBase = parseReprintBaseTime(row) | |
| 442 | 539 | tmpl = overlayReprintResolvedFields(tmpl, row, templateData) |
| 443 | - tmpl = bakeReprintTemplateSnapshot(tmpl) | |
| 540 | + tmpl = bakeReprintTemplateSnapshot(tmpl, reprintBase) | |
| 444 | 541 | |
| 445 | 542 | tmpl = { |
| 446 | 543 | ...tmpl, |
| ... | ... | @@ -571,8 +668,9 @@ export async function printFromMergedTemplateJsonString ( |
| 571 | 668 | throw new Error('Cannot parse merged template') |
| 572 | 669 | } |
| 573 | 670 | const templateData = labelTemplateDataForSnapshotReprint() |
| 671 | + const reprintBase = parseReprintBaseTime(row) | |
| 574 | 672 | tmpl = overlayReprintResolvedFields(tmpl, row, templateData) |
| 575 | - tmpl = bakeReprintTemplateSnapshot(tmpl) | |
| 673 | + tmpl = bakeReprintTemplateSnapshot(tmpl, reprintBase) | |
| 576 | 674 | tmpl = { |
| 577 | 675 | ...tmpl, |
| 578 | 676 | elements: sortElementsForPreview(tmpl.elements || []), | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/resolveMediaUrl.ts
| 1 | 1 | import { buildApiUrl, getStaticMediaOrigin } from './apiBase' |
| 2 | 2 | |
| 3 | -/** 相对路径拼后端根(含 H5 开发时的静态资源域名);绝对 URL / data URL 原样返回 */ | |
| 3 | +/** 相对路径拼后端根;绝对 URL / data URL 原样返回 */ | |
| 4 | 4 | export function resolveMediaUrlForApp(stored: string | null | undefined): string { |
| 5 | 5 | const s = (stored ?? '').trim() |
| 6 | 6 | if (!s) return '' | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/smartScaleService.ts
0 → 100644
| 1 | +export type SmartScaleReadKind = 'tared' | 'gross' | |
| 2 | + | |
| 3 | +const STORAGE_IP = 'smart_scale_ip' | |
| 4 | +const STORAGE_PORT = 'smart_scale_port' | |
| 5 | +const DEFAULT_IP = '127.0.0.1' | |
| 6 | +const DEFAULT_PORT = 6900 | |
| 7 | +const READ_TIMEOUT_MS = 4000 | |
| 8 | + | |
| 9 | +type MoeTcpPlugin = { | |
| 10 | + connect: (opts: { ip: string; port: number }, cb: (res: { code?: number; msg?: string }) => void) => void | |
| 11 | + disconnect: () => void | |
| 12 | + sendStr: (opts: { message: string }) => void | |
| 13 | + onReceive: (cb: (res: { code?: number; data?: string; msg?: string }) => void) => void | |
| 14 | + onDisconnect: (cb: (res: unknown) => void) => void | |
| 15 | +} | |
| 16 | + | |
| 17 | +function getTcpPlugin(): MoeTcpPlugin | null { | |
| 18 | + // #ifdef APP-PLUS | |
| 19 | + try { | |
| 20 | + const u = uni as any | |
| 21 | + return u?.requireNativePlugin ? (u.requireNativePlugin('moe-tcp-client') as MoeTcpPlugin) : null | |
| 22 | + } catch { | |
| 23 | + return null | |
| 24 | + } | |
| 25 | + // #endif | |
| 26 | + // #ifndef APP-PLUS | |
| 27 | + return null | |
| 28 | + // #endif | |
| 29 | +} | |
| 30 | + | |
| 31 | +function readScaleSettings(): { ip: string; port: number } { | |
| 32 | + let ip = DEFAULT_IP | |
| 33 | + let port = DEFAULT_PORT | |
| 34 | + try { | |
| 35 | + const storedIp = uni.getStorageSync(STORAGE_IP) | |
| 36 | + const storedPort = uni.getStorageSync(STORAGE_PORT) | |
| 37 | + if (storedIp) ip = String(storedIp).trim() || ip | |
| 38 | + const p = Number(storedPort) | |
| 39 | + if (Number.isFinite(p) && p > 0) port = p | |
| 40 | + } catch { | |
| 41 | + /* ignore */ | |
| 42 | + } | |
| 43 | + return { ip, port } | |
| 44 | +} | |
| 45 | + | |
| 46 | +function parseWeightFromMessage(msg: string): string | null { | |
| 47 | + const text = String(msg ?? '').replace(/,/g, '.') | |
| 48 | + const matches = text.match(/-?\d+(?:\.\d+)?/g) | |
| 49 | + if (!matches?.length) return null | |
| 50 | + const last = matches[matches.length - 1] | |
| 51 | + const n = Number(last) | |
| 52 | + if (!Number.isFinite(n)) return null | |
| 53 | + return String(n) | |
| 54 | +} | |
| 55 | + | |
| 56 | +function connectTcp(plugin: MoeTcpPlugin, ip: string, port: number): Promise<void> { | |
| 57 | + return new Promise((resolve, reject) => { | |
| 58 | + plugin.connect({ ip, port }, (res) => { | |
| 59 | + if (res?.code === 1) resolve() | |
| 60 | + else reject(new Error(res?.msg || 'Could not connect to smart scale.')) | |
| 61 | + }) | |
| 62 | + }) | |
| 63 | +} | |
| 64 | + | |
| 65 | +/** Read weight from smart scale (TCP). `tared` sends tare then reads net; `gross` reads gross. */ | |
| 66 | +export async function readWeightFromSmartScale(kind: SmartScaleReadKind): Promise<string> { | |
| 67 | + const plugin = getTcpPlugin() | |
| 68 | + if (!plugin) { | |
| 69 | + throw new Error('Smart scale is only available in the mobile app.') | |
| 70 | + } | |
| 71 | + | |
| 72 | + const { ip, port } = readScaleSettings() | |
| 73 | + let latest: string | null = null | |
| 74 | + let settled = false | |
| 75 | + | |
| 76 | + return new Promise<string>((resolve, reject) => { | |
| 77 | + const finish = (err?: Error) => { | |
| 78 | + if (settled) return | |
| 79 | + settled = true | |
| 80 | + try { | |
| 81 | + plugin.onReceive(() => {}) | |
| 82 | + plugin.onDisconnect(() => {}) | |
| 83 | + plugin.disconnect() | |
| 84 | + } catch { | |
| 85 | + /* ignore */ | |
| 86 | + } | |
| 87 | + if (err) reject(err) | |
| 88 | + else if (latest) resolve(latest) | |
| 89 | + else reject(new Error('No weight reading received from scale.')) | |
| 90 | + } | |
| 91 | + | |
| 92 | + const timer = setTimeout(() => finish(new Error('Smart scale read timed out.')), READ_TIMEOUT_MS) | |
| 93 | + | |
| 94 | + plugin.onReceive((res) => { | |
| 95 | + if (res?.code !== 1) return | |
| 96 | + const parsed = parseWeightFromMessage(String(res.data ?? '')) | |
| 97 | + if (parsed) latest = parsed | |
| 98 | + }) | |
| 99 | + | |
| 100 | + plugin.onDisconnect(() => { | |
| 101 | + if (!settled && latest) { | |
| 102 | + clearTimeout(timer) | |
| 103 | + finish() | |
| 104 | + } | |
| 105 | + }) | |
| 106 | + | |
| 107 | + connectTcp(plugin, ip, port) | |
| 108 | + .then(() => { | |
| 109 | + if (kind === 'tared') { | |
| 110 | + try { | |
| 111 | + plugin.sendStr({ message: 'T\r\n' }) | |
| 112 | + } catch { | |
| 113 | + /* ignore */ | |
| 114 | + } | |
| 115 | + setTimeout(() => { | |
| 116 | + try { | |
| 117 | + plugin.sendStr({ message: 'W\r\n' }) | |
| 118 | + } catch { | |
| 119 | + /* ignore */ | |
| 120 | + } | |
| 121 | + }, 600) | |
| 122 | + } else { | |
| 123 | + try { | |
| 124 | + plugin.sendStr({ message: 'W\r\n' }) | |
| 125 | + } catch { | |
| 126 | + /* ignore */ | |
| 127 | + } | |
| 128 | + } | |
| 129 | + }) | |
| 130 | + .catch((e) => { | |
| 131 | + clearTimeout(timer) | |
| 132 | + finish(e instanceof Error ? e : new Error(String(e))) | |
| 133 | + }) | |
| 134 | + | |
| 135 | + const poll = setInterval(() => { | |
| 136 | + if (latest && !settled) { | |
| 137 | + clearInterval(poll) | |
| 138 | + clearTimeout(timer) | |
| 139 | + finish() | |
| 140 | + } | |
| 141 | + }, 200) | |
| 142 | + }) | |
| 143 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/sqliteSync.ts
| ... | ... | @@ -207,6 +207,28 @@ export async function hasOfflineCache(module: string, name: string): Promise<boo |
| 207 | 207 | return rows.length > 0 |
| 208 | 208 | } |
| 209 | 209 | |
| 210 | +/** 离线读缓存:匹配以 prefix 开头的 cache_name(兼容旧版 preview 键带 baseTime 后缀) */ | |
| 211 | +async function findOfflineCacheKeyByPrefix(module: string, namePrefix: string): Promise<string | null> { | |
| 212 | + if (!namePrefix) return null | |
| 213 | + if (!isAppSqliteAvailable()) { | |
| 214 | + const map = getStorageMap<{ module: string; name: string }>(FALLBACK_CACHE_KEY) | |
| 215 | + for (const row of Object.values(map)) { | |
| 216 | + const m = String((row as { module?: string }).module || '') | |
| 217 | + const n = String((row as { name?: string }).name || '') | |
| 218 | + if (m === module && n.startsWith(namePrefix)) return n | |
| 219 | + } | |
| 220 | + return null | |
| 221 | + } | |
| 222 | + await initOfflineSqlite() | |
| 223 | + const rows = await sqliteSelect( | |
| 224 | + `SELECT cache_name FROM ${CACHE_TABLE} | |
| 225 | + WHERE module_name='${esc(module)}' AND cache_name LIKE '${esc(namePrefix)}%' | |
| 226 | + ORDER BY updated_at DESC LIMIT 1` | |
| 227 | + ) | |
| 228 | + const hit = rows?.[0]?.cache_name | |
| 229 | + return hit != null && String(hit).trim() ? String(hit) : null | |
| 230 | +} | |
| 231 | + | |
| 210 | 232 | export async function getOfflineCache<T = unknown>(module: string, name: string): Promise<T | null> { |
| 211 | 233 | const key = cachePk(module, name) |
| 212 | 234 | if (!isAppSqliteAvailable()) { |
| ... | ... | @@ -399,6 +421,14 @@ export async function fetchWithOfflineCache<T>( |
| 399 | 421 | if (await hasOfflineCache(module, name)) { |
| 400 | 422 | return (await getOfflineCache<T>(module, name)) as T |
| 401 | 423 | } |
| 424 | + /** 兼容旧版 preview 缓存键(含 baseTime 分钟桶) */ | |
| 425 | + if (name.startsWith('preview:')) { | |
| 426 | + const legacyPrefix = `${name}:` | |
| 427 | + const legacyHit = await findOfflineCacheKeyByPrefix(module, legacyPrefix) | |
| 428 | + if (legacyHit) { | |
| 429 | + return (await getOfflineCache<T>(module, legacyHit)) as T | |
| 430 | + } | |
| 431 | + } | |
| 402 | 432 | throw new Error('No offline cache available') |
| 403 | 433 | } |
| 404 | 434 | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/textElementLayout.ts
0 → 100644
| 1 | +/** 文本控件在元素框内的垂直对齐(config.verticalAlign) */ | |
| 2 | +export type TextVerticalAlign = 'top' | 'center' | 'bottom' | |
| 3 | + | |
| 4 | +export function readVerticalAlign( | |
| 5 | + config: Record<string, unknown> | undefined | null, | |
| 6 | +): TextVerticalAlign { | |
| 7 | + const raw = config?.verticalAlign ?? config?.VerticalAlign | |
| 8 | + const v = String(raw ?? 'top').trim().toLowerCase() | |
| 9 | + if (v === 'center' || v === 'middle') return 'center' | |
| 10 | + if (v === 'bottom') return 'bottom' | |
| 11 | + return 'top' | |
| 12 | +} | |
| 13 | + | |
| 14 | +export function computeVerticalTextBlockOffset( | |
| 15 | + innerHeight: number, | |
| 16 | + blockHeight: number, | |
| 17 | + verticalAlign: TextVerticalAlign, | |
| 18 | +): number { | |
| 19 | + const extra = Math.max(0, innerHeight - blockHeight) | |
| 20 | + if (verticalAlign === 'center') return Math.floor(extra / 2) | |
| 21 | + if (verticalAlign === 'bottom') return extra | |
| 22 | + return 0 | |
| 23 | +} | |
| 24 | + | |
| 25 | +export function readFontWeight( | |
| 26 | + config: Record<string, unknown> | undefined | null, | |
| 27 | +): 'normal' | 'bold' { | |
| 28 | + const raw = config?.fontWeight ?? config?.FontWeight | |
| 29 | + const v = String(raw ?? 'normal').trim().toLowerCase() | |
| 30 | + return v === 'bold' || v === '700' || v === 'bolder' ? 'bold' : 'normal' | |
| 31 | +} | |
| 32 | + | |
| 33 | +export function readFontStyle( | |
| 34 | + config: Record<string, unknown> | undefined | null, | |
| 35 | +): 'normal' | 'italic' { | |
| 36 | + const raw = config?.fontStyle ?? config?.FontStyle | |
| 37 | + const v = String(raw ?? 'normal').trim().toLowerCase() | |
| 38 | + return v === 'italic' || v === 'oblique' ? 'italic' : 'normal' | |
| 39 | +} | |
| 40 | + | |
| 41 | +export function readTextDecoration( | |
| 42 | + config: Record<string, unknown> | undefined | null, | |
| 43 | +): 'none' | 'underline' { | |
| 44 | + const raw = config?.textDecoration ?? config?.TextDecoration | |
| 45 | + const v = String(raw ?? 'none').trim().toLowerCase() | |
| 46 | + return v.includes('underline') ? 'underline' : 'none' | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function readElementBorder( | |
| 50 | + el: { | |
| 51 | + border?: string | null | |
| 52 | + Border?: string | null | |
| 53 | + BorderType?: string | null | |
| 54 | + borderType?: string | null | |
| 55 | + }, | |
| 56 | +): string { | |
| 57 | + const raw = el.border ?? el.Border ?? el.BorderType ?? el.borderType ?? 'none' | |
| 58 | + return String(raw).trim().toLowerCase() || 'none' | |
| 59 | +} | |
| 60 | + | |
| 61 | +/** 元素 rotation(兼容大小写) */ | |
| 62 | +export function readElementRotation( | |
| 63 | + el: { rotation?: string | null; Rotation?: string | null }, | |
| 64 | +): 'horizontal' | 'vertical' | 'right' | 'rotate180' { | |
| 65 | + const v = String(el.rotation ?? el.Rotation ?? 'horizontal').trim().toLowerCase() | |
| 66 | + if (v === 'vertical' || v === 'left' || v === 'rotate-left' || v === 'left90') return 'vertical' | |
| 67 | + if (v === 'right' || v === 'rotate-right' || v === 'right90') return 'right' | |
| 68 | + if (v === 'rotate180' || v === '180' || v === 'down' || v === 'inverted') return 'rotate180' | |
| 69 | + return 'horizontal' | |
| 70 | +} | |
| 71 | + | |
| 72 | +export function elementRotationDegrees( | |
| 73 | + el: { rotation?: string | null; Rotation?: string | null }, | |
| 74 | +): 0 | -90 | 90 | 180 { | |
| 75 | + const rotation = readElementRotation(el) | |
| 76 | + if (rotation === 'vertical') return -90 | |
| 77 | + if (rotation === 'right') return 90 | |
| 78 | + if (rotation === 'rotate180') return 180 | |
| 79 | + return 0 | |
| 80 | +} | |
| 81 | + | |
| 82 | +/** 与 renderLabelPreviewCanvas lineHeightForTextElement 一致 */ | |
| 83 | +export function textElementLineHeightPx(fontSize: number, isDateTimeType = false): number { | |
| 84 | + return isDateTimeType | |
| 85 | + ? Math.max(fontSize + 2, Math.round(fontSize * 1.2)) | |
| 86 | + : Math.max(fontSize + 2, Math.round(fontSize * 1.25)) | |
| 87 | +} | |
| 88 | + | |
| 89 | +/** 矮框内固定文案:强制单行,避免折行后仅显示第一行 */ | |
| 90 | +export function isShortSingleLineTextBox( | |
| 91 | + boxHeightPx: number, | |
| 92 | + fontSize: number, | |
| 93 | + text: string, | |
| 94 | + opts?: { breakAll?: boolean }, | |
| 95 | +): boolean { | |
| 96 | + if (opts?.breakAll) return false | |
| 97 | + if (String(text ?? '').includes('\n')) return false | |
| 98 | + const lineH = textElementLineHeightPx(fontSize, false) | |
| 99 | + const descentPad = Math.max(2, Math.round(fontSize * 0.14)) | |
| 100 | + const maxLines = Math.max( | |
| 101 | + 1, | |
| 102 | + Math.floor((Math.max(0, boxHeightPx) + descentPad) / lineH), | |
| 103 | + ) | |
| 104 | + return maxLines <= 1 | |
| 105 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/src/utils/weightElement.ts
0 → 100644
| 1 | +export type WeightInputMode = 'net' | 'tare' | |
| 2 | + | |
| 3 | +export function readWeightInputMode(cfg: Record<string, unknown> | undefined | null): WeightInputMode { | |
| 4 | + const v = String(cfg?.weightInputMode ?? cfg?.WeightInputMode ?? 'net') | |
| 5 | + .trim() | |
| 6 | + .toLowerCase() | |
| 7 | + return v === 'tare' ? 'tare' : 'net' | |
| 8 | +} | |
| 9 | + | |
| 10 | +export function formatWeightDisplay(rawValue: string, unit: string): string { | |
| 11 | + const raw = String(rawValue ?? '').trim() | |
| 12 | + const u = String(unit ?? '').trim() | |
| 13 | + if (!raw) return '' | |
| 14 | + if (u && !raw.endsWith(u)) return `${raw}${u}` | |
| 15 | + return raw | |
| 16 | +} | |
| 17 | + | |
| 18 | +export function weightInputPlaceholder(mode: WeightInputMode): string { | |
| 19 | + return mode === 'tare' ? 'Tare weight' : 'Net weight' | |
| 20 | +} | ... | ... |
泰额版/Food Labeling Management App UniApp/static/fonts/freight-sans-bold/FreightSans-Bold.ttf
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/lato/lato-latin-400-italic.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/lato/lato-latin-400-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/lato/lato-latin-700-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/open-sans/open-sans-latin-400-italic.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/open-sans/open-sans-latin-400-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/open-sans/open-sans-latin-700-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/roboto-mono/roboto-mono-latin-400-italic.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/roboto-mono/roboto-mono-latin-400-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/roboto-mono/roboto-mono-latin-700-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/roboto/roboto-latin-400-italic.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/roboto/roboto-latin-400-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/roboto/roboto-latin-700-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/tinos/tinos-latin-400-italic.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/tinos/tinos-latin-400-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/static/fonts/tinos/tinos-latin-700-normal.woff2
0 → 100644
No preview for this file type
泰额版/Food Labeling Management App UniApp/vite.config.ts
| 1 | 1 | import { defineConfig } from "vite"; |
| 2 | 2 | import uni from "@dcloudio/vite-plugin-uni"; |
| 3 | 3 | |
| 4 | -/** 与 src/utils/apiBase.ts 中 US_BACKEND_ORIGIN_FALLBACK 保持一致 */ | |
| 5 | -const US_DEV_BACKEND = "http://flus-test.3ffoodsafety.com"; | |
| 6 | - | |
| 7 | 4 | // 仅 App:相对路径,避免打包后 www/_uniappview.html 无法打开 |
| 5 | +// API 直连后端,见 .env.development / .env.production 中的 VITE_US_API_BASE | |
| 8 | 6 | // https://vitejs.dev/config/ |
| 9 | 7 | export default defineConfig({ |
| 10 | 8 | base: "./", |
| 11 | 9 | plugins: [uni()], |
| 12 | 10 | server: { |
| 13 | 11 | open: "/", |
| 14 | - proxy: { | |
| 15 | - "/api": { | |
| 16 | - target: US_DEV_BACKEND, | |
| 17 | - changeOrigin: true, | |
| 18 | - }, | |
| 19 | - /** 若某处仍使用相对路径 /picture(未走 getStaticMediaOrigin),可经代理访问后端静态资源 */ | |
| 20 | - "/picture": { | |
| 21 | - target: US_DEV_BACKEND, | |
| 22 | - changeOrigin: true, | |
| 23 | - }, | |
| 24 | - }, | |
| 25 | 12 | }, |
| 26 | 13 | }); | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/data-entry/index.vue
| 1 | 1 | <script lang="ts" setup> |
| 2 | -import type { LabelTemplateDto, LabelTypeDto, ProductDto } from '#/api/food-labeling/types'; | |
| 2 | +import type { LabelCategoryDto, LabelTemplateDto, LabelTypeDto, ProductDto } from '#/api/food-labeling/types'; | |
| 3 | +import type { AccountLocationDto, GroupListItemDto, PartnerDto } from '#/api/food-labeling/account-types'; | |
| 4 | +import type { ProductCategoryDto } from '#/api/food-labeling/product-types'; | |
| 3 | 5 | |
| 4 | 6 | import { computed, onMounted, ref } from 'vue'; |
| 5 | 7 | import { useRoute, useRouter } from 'vue-router'; |
| ... | ... | @@ -8,11 +10,17 @@ import { Page } from '@vben/common-ui'; |
| 8 | 10 | import { $t } from '@vben/locales'; |
| 9 | 11 | |
| 10 | 12 | import { ArrowLeftOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons-vue'; |
| 11 | -import { Input, InputNumber, message, Select, Table } from 'ant-design-vue'; | |
| 13 | +import { Input, InputNumber, message, Select, Switch, Table } from 'ant-design-vue'; | |
| 12 | 14 | |
| 15 | +import { labelAdd } from '#/api/food-labeling/label'; | |
| 16 | +import { labelCategoryList } from '#/api/food-labeling/label-category'; | |
| 13 | 17 | import { labelTemplateInfo, labelTemplateUpdate } from '#/api/food-labeling/label-template'; |
| 14 | 18 | import { labelTypeList } from '#/api/food-labeling/label-type'; |
| 19 | +import { groupList } from '#/api/food-labeling/group'; | |
| 20 | +import { locationCrudList } from '#/api/food-labeling/location'; | |
| 21 | +import { partnerList } from '#/api/food-labeling/partner'; | |
| 15 | 22 | import { productList } from '#/api/food-labeling/product'; |
| 23 | +import { productCategoryList } from '#/api/food-labeling/product-category'; | |
| 16 | 24 | |
| 17 | 25 | import { |
| 18 | 26 | appliedLocationToEditor, |
| ... | ... | @@ -50,8 +58,15 @@ import NutritionManualFieldControl from './nutrition-manual-field-control.vue'; |
| 50 | 58 | |
| 51 | 59 | type TemplateDataEntryRow = { |
| 52 | 60 | id: string; |
| 61 | + partnerId: string; | |
| 62 | + regionIds: string[]; | |
| 63 | + productCategoryId: string; | |
| 53 | 64 | productId: string; |
| 65 | + labelName: string; | |
| 66 | + locationId: string; | |
| 67 | + labelCategoryId: string; | |
| 54 | 68 | labelTypeId: string; |
| 69 | + state: boolean; | |
| 55 | 70 | fieldValues: Record<string, string>; |
| 56 | 71 | }; |
| 57 | 72 | |
| ... | ... | @@ -69,7 +84,12 @@ const loading = ref(true); |
| 69 | 84 | const saving = ref(false); |
| 70 | 85 | const templateTitle = ref(''); |
| 71 | 86 | const templateDto = ref<LabelTemplateDto | null>(null); |
| 87 | +const partners = ref<PartnerDto[]>([]); | |
| 88 | +const groups = ref<GroupListItemDto[]>([]); | |
| 89 | +const locations = ref<AccountLocationDto[]>([]); | |
| 90 | +const productCategories = ref<ProductCategoryDto[]>([]); | |
| 72 | 91 | const products = ref<ProductDto[]>([]); |
| 92 | +const labelCategories = ref<LabelCategoryDto[]>([]); | |
| 73 | 93 | const labelTypes = ref<LabelTypeDto[]>([]); |
| 74 | 94 | const rows = ref<TemplateDataEntryRow[]>([]); |
| 75 | 95 | |
| ... | ... | @@ -77,6 +97,22 @@ function newRowId(): string { |
| 77 | 97 | return `row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; |
| 78 | 98 | } |
| 79 | 99 | |
| 100 | +function createEmptyRow(): TemplateDataEntryRow { | |
| 101 | + return { | |
| 102 | + id: newRowId(), | |
| 103 | + partnerId: '', | |
| 104 | + regionIds: [], | |
| 105 | + productCategoryId: '', | |
| 106 | + productId: '', | |
| 107 | + labelName: '', | |
| 108 | + locationId: '', | |
| 109 | + labelCategoryId: '', | |
| 110 | + labelTypeId: '', | |
| 111 | + state: true, | |
| 112 | + fieldValues: {}, | |
| 113 | + }; | |
| 114 | +} | |
| 115 | + | |
| 80 | 116 | const sortedTemplateElements = computed(() => |
| 81 | 117 | sortTemplateElementsForDisplay((templateDto.value?.elements ?? []) as EditorLabelElement[]), |
| 82 | 118 | ); |
| ... | ... | @@ -94,6 +130,36 @@ const dataColumns = computed((): DataColumn[] => { |
| 94 | 130 | return cols; |
| 95 | 131 | }); |
| 96 | 132 | |
| 133 | +const partnerOptions = computed(() => | |
| 134 | + partners.value.map((p) => ({ | |
| 135 | + value: p.id, | |
| 136 | + label: (p.partnerName ?? p.id ?? '').trim() || p.id, | |
| 137 | + })), | |
| 138 | +); | |
| 139 | + | |
| 140 | +function groupOptionsForRow(row: Partial<TemplateDataEntryRow>) { | |
| 141 | + return groups.value | |
| 142 | + .filter((g) => !row.partnerId || g.partnerId === row.partnerId) | |
| 143 | + .map((g) => ({ | |
| 144 | + value: g.id, | |
| 145 | + label: (g.groupName ?? g.id ?? '').trim() || g.id, | |
| 146 | + })); | |
| 147 | +} | |
| 148 | + | |
| 149 | +function categoryOptionsForRow(row: Partial<TemplateDataEntryRow>) { | |
| 150 | + const regionSet = new Set(row.regionIds ?? []); | |
| 151 | + return productCategories.value | |
| 152 | + .filter((c) => { | |
| 153 | + const gids = c.groupIds ?? c.regionIds ?? []; | |
| 154 | + if (regionSet.size === 0) return true; | |
| 155 | + return gids.length === 0 || gids.some((id) => regionSet.has(id)); | |
| 156 | + }) | |
| 157 | + .map((c) => ({ | |
| 158 | + value: c.id, | |
| 159 | + label: (c.categoryName ?? c.categoryCode ?? c.id ?? '').trim() || c.id, | |
| 160 | + })); | |
| 161 | +} | |
| 162 | + | |
| 97 | 163 | const productOptions = computed(() => |
| 98 | 164 | products.value.map((p) => ({ |
| 99 | 165 | value: p.id, |
| ... | ... | @@ -101,6 +167,48 @@ const productOptions = computed(() => |
| 101 | 167 | })), |
| 102 | 168 | ); |
| 103 | 169 | |
| 170 | +function productOptionsForRow(row: Partial<TemplateDataEntryRow>) { | |
| 171 | + const regionSet = new Set(row.regionIds ?? []); | |
| 172 | + return products.value | |
| 173 | + .filter((p) => { | |
| 174 | + if (row.productCategoryId && p.categoryId !== row.productCategoryId) return false; | |
| 175 | + if (row.partnerId) { | |
| 176 | + const pids = p.partnerIds ?? (p.partnerId ? [p.partnerId] : []); | |
| 177 | + if (pids.length && !pids.includes(row.partnerId)) return false; | |
| 178 | + } | |
| 179 | + const gids = p.groupIds ?? []; | |
| 180 | + if (regionSet.size > 0 && gids.length && !gids.some((id) => regionSet.has(id))) return false; | |
| 181 | + return true; | |
| 182 | + }) | |
| 183 | + .map((p) => ({ | |
| 184 | + value: p.id, | |
| 185 | + label: [p.productName, p.productCode].filter(Boolean).join(' · ') || p.id, | |
| 186 | + })); | |
| 187 | +} | |
| 188 | + | |
| 189 | +function locationOptionsForRow(row: Partial<TemplateDataEntryRow>) { | |
| 190 | + const selectedGroups = groups.value.filter((g) => (row.regionIds ?? []).includes(g.id)); | |
| 191 | + const groupNames = new Set(selectedGroups.map((g) => String(g.groupName ?? '').trim()).filter(Boolean)); | |
| 192 | + const partnerName = partners.value.find((p) => p.id === row.partnerId)?.partnerName; | |
| 193 | + return locations.value | |
| 194 | + .filter((l) => { | |
| 195 | + if (partnerName && l.partner && l.partner !== partnerName) return false; | |
| 196 | + if (groupNames.size && l.groupName && !groupNames.has(l.groupName)) return false; | |
| 197 | + return true; | |
| 198 | + }) | |
| 199 | + .map((l) => ({ | |
| 200 | + value: l.id, | |
| 201 | + label: [l.locationName, l.locationCode].filter(Boolean).join(' · ') || l.id, | |
| 202 | + })); | |
| 203 | +} | |
| 204 | + | |
| 205 | +const labelCategoryOptions = computed(() => | |
| 206 | + labelCategories.value.map((c) => ({ | |
| 207 | + value: c.id, | |
| 208 | + label: (c.categoryName ?? c.categoryCode ?? c.id ?? '').trim() || c.id, | |
| 209 | + })), | |
| 210 | +); | |
| 211 | + | |
| 104 | 212 | const labelTypeOptions = computed(() => |
| 105 | 213 | labelTypes.value.map((t) => ({ |
| 106 | 214 | value: t.id, |
| ... | ... | @@ -108,6 +216,36 @@ const labelTypeOptions = computed(() => |
| 108 | 216 | })), |
| 109 | 217 | ); |
| 110 | 218 | |
| 219 | +const tableColumns = computed(() => { | |
| 220 | + const cols: Array<Record<string, unknown>> = []; | |
| 221 | + if (fromLabelsBulk.value) { | |
| 222 | + cols.push( | |
| 223 | + { title: 'Company *', key: 'partner', width: 190, fixed: 'left' }, | |
| 224 | + { title: 'Applicable Region *', key: 'region', width: 220, fixed: 'left' }, | |
| 225 | + { title: 'Product category *', key: 'productCategory', width: 200 }, | |
| 226 | + ); | |
| 227 | + } | |
| 228 | + cols.push({ title: 'Product *', key: 'product', width: 200, ...(fromLabelsBulk.value ? {} : { fixed: 'left' }) }); | |
| 229 | + if (fromLabelsBulk.value) { | |
| 230 | + cols.push( | |
| 231 | + { title: 'Label Name *', key: 'labelName', width: 220 }, | |
| 232 | + { title: 'Location *', key: 'location', width: 220 }, | |
| 233 | + { title: 'Label Category *', key: 'labelCategory', width: 200 }, | |
| 234 | + ); | |
| 235 | + } | |
| 236 | + cols.push({ title: 'Label type *', key: 'labelType', width: 180, ...(fromLabelsBulk.value ? {} : { fixed: 'left' }) }); | |
| 237 | + cols.push( | |
| 238 | + ...dataColumns.value.map((col) => ({ | |
| 239 | + title: columnTitle(col), | |
| 240 | + key: columnKey(col), | |
| 241 | + width: col.kind === 'element' && dataEntryUsesImageUpload(col.el) ? 120 : 140, | |
| 242 | + })), | |
| 243 | + ); | |
| 244 | + if (fromLabelsBulk.value) cols.push({ title: 'Enabled', key: 'state', width: 110, fixed: 'right' }); | |
| 245 | + cols.push({ title: '', key: 'action', width: 72, fixed: 'right' }); | |
| 246 | + return cols; | |
| 247 | +}); | |
| 248 | + | |
| 111 | 249 | function columnKey(col: DataColumn): string { |
| 112 | 250 | return col.kind === 'element' |
| 113 | 251 | ? col.el.id |
| ... | ... | @@ -157,17 +295,27 @@ async function loadData() { |
| 157 | 295 | if (FOOD_LABELING_STATIC_ONLY) { |
| 158 | 296 | message.info($t('foodLabeling.common.staticDemoBanner')); |
| 159 | 297 | templateTitle.value = templateCode.value; |
| 160 | - rows.value = [{ id: newRowId(), productId: '', labelTypeId: '', fieldValues: {} }]; | |
| 298 | + rows.value = [createEmptyRow()]; | |
| 161 | 299 | return; |
| 162 | 300 | } |
| 163 | - const [tpl, prodRes, typeRes] = await Promise.all([ | |
| 301 | + const [tpl, partnerRes, groupRes, locRes, prodCatRes, prodRes, labelCatRes, typeRes] = await Promise.all([ | |
| 164 | 302 | labelTemplateInfo(templateCode.value), |
| 303 | + partnerList({ SkipCount: 1, MaxResultCount: 500, State: true }), | |
| 304 | + groupList({ SkipCount: 1, MaxResultCount: 500, State: true }), | |
| 305 | + locationCrudList({ SkipCount: 1, MaxResultCount: 1000, State: true }), | |
| 306 | + productCategoryList({ SkipCount: 1, MaxResultCount: 500, State: true }), | |
| 165 | 307 | productList({ SkipCount: 1, MaxResultCount: 500 }), |
| 308 | + labelCategoryList({ SkipCount: 1, MaxResultCount: 500, State: true }), | |
| 166 | 309 | labelTypeList({ SkipCount: 1, MaxResultCount: 500 }), |
| 167 | 310 | ]); |
| 168 | 311 | templateTitle.value = |
| 169 | 312 | (tpl.templateName ?? tpl.name ?? tpl.templateCode ?? tpl.id ?? '').trim() || templateCode.value; |
| 313 | + partners.value = partnerRes?.items ?? []; | |
| 314 | + groups.value = groupRes?.items ?? []; | |
| 315 | + locations.value = locRes?.items ?? []; | |
| 316 | + productCategories.value = prodCatRes?.items ?? []; | |
| 170 | 317 | products.value = prodRes?.items ?? []; |
| 318 | + labelCategories.value = labelCatRes?.items ?? []; | |
| 171 | 319 | labelTypes.value = typeRes?.items ?? []; |
| 172 | 320 | templateDto.value = tpl; |
| 173 | 321 | |
| ... | ... | @@ -175,7 +323,7 @@ async function loadData() { |
| 175 | 323 | const fromApi = defaults.length > 0 ? [...defaults].sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0)) : []; |
| 176 | 324 | if (fromApi.length > 0) { |
| 177 | 325 | rows.value = fromApi.map((d) => ({ |
| 178 | - id: newRowId(), | |
| 326 | + ...createEmptyRow(), | |
| 179 | 327 | productId: d.productId, |
| 180 | 328 | labelTypeId: d.labelTypeId, |
| 181 | 329 | fieldValues: hydrateRowFieldValuesWithNutritionColumns( |
| ... | ... | @@ -184,7 +332,7 @@ async function loadData() { |
| 184 | 332 | ), |
| 185 | 333 | })); |
| 186 | 334 | } else { |
| 187 | - rows.value = [{ id: newRowId(), productId: '', labelTypeId: '', fieldValues: {} }]; | |
| 335 | + rows.value = [createEmptyRow()]; | |
| 188 | 336 | } |
| 189 | 337 | } catch { |
| 190 | 338 | message.error('Failed to load template or options.'); |
| ... | ... | @@ -200,7 +348,7 @@ onMounted(() => { |
| 200 | 348 | }); |
| 201 | 349 | |
| 202 | 350 | function addRow() { |
| 203 | - rows.value = [...rows.value, { id: newRowId(), productId: '', labelTypeId: '', fieldValues: {} }]; | |
| 351 | + rows.value = [...rows.value, createEmptyRow()]; | |
| 204 | 352 | } |
| 205 | 353 | |
| 206 | 354 | function removeRow(id: string) { |
| ... | ... | @@ -214,6 +362,36 @@ function setFieldValue(rowId: string, fieldKey: string, value: string) { |
| 214 | 362 | ); |
| 215 | 363 | } |
| 216 | 364 | |
| 365 | +function updateRow(rowId: string, patch: Partial<TemplateDataEntryRow>) { | |
| 366 | + rows.value = rows.value.map((r) => (r.id === rowId ? { ...r, ...patch } : r)); | |
| 367 | +} | |
| 368 | + | |
| 369 | +function applyPartnerToRow(rowId: string, partnerId: string) { | |
| 370 | + updateRow(rowId, { | |
| 371 | + partnerId, | |
| 372 | + regionIds: [], | |
| 373 | + productCategoryId: '', | |
| 374 | + productId: '', | |
| 375 | + locationId: '', | |
| 376 | + }); | |
| 377 | +} | |
| 378 | + | |
| 379 | +function applyRegionsToRow(rowId: string, regionIds: string[]) { | |
| 380 | + updateRow(rowId, { | |
| 381 | + regionIds, | |
| 382 | + productCategoryId: '', | |
| 383 | + productId: '', | |
| 384 | + locationId: '', | |
| 385 | + }); | |
| 386 | +} | |
| 387 | + | |
| 388 | +function applyProductCategoryToRow(rowId: string, productCategoryId: string) { | |
| 389 | + updateRow(rowId, { | |
| 390 | + productCategoryId, | |
| 391 | + productId: '', | |
| 392 | + }); | |
| 393 | +} | |
| 394 | + | |
| 217 | 395 | function applyProductToRow(rowId: string, productId: string) { |
| 218 | 396 | const pid = productId.trim(); |
| 219 | 397 | const product = products.value.find((p) => p.id === pid); |
| ... | ... | @@ -247,6 +425,10 @@ async function handleSave() { |
| 247 | 425 | message.success($t('foodLabeling.common.staticDemoAction')); |
| 248 | 426 | return; |
| 249 | 427 | } |
| 428 | + if (fromLabelsBulk.value) { | |
| 429 | + await handleBulkLabelSave(); | |
| 430 | + return; | |
| 431 | + } | |
| 250 | 432 | const touched = rows.value.filter((r) => r.productId.trim() || r.labelTypeId.trim()); |
| 251 | 433 | const incomplete = touched.some((r) => !r.productId.trim() || !r.labelTypeId.trim()); |
| 252 | 434 | if (incomplete) { |
| ... | ... | @@ -277,6 +459,7 @@ async function handleSave() { |
| 277 | 459 | Object.assign( |
| 278 | 460 | defaultValues, |
| 279 | 461 | buildTemplateLabelBindingDefaults(fullElements, { |
| 462 | + labelName: r.labelName, | |
| 280 | 463 | labelTypeName: labelType?.typeName ?? labelType?.typeCode, |
| 281 | 464 | }), |
| 282 | 465 | ); |
| ... | ... | @@ -319,6 +502,138 @@ async function handleSave() { |
| 319 | 502 | } |
| 320 | 503 | } |
| 321 | 504 | |
| 505 | +async function handleBulkLabelSave() { | |
| 506 | + if (!templateDto.value) { | |
| 507 | + message.error('Template not loaded'); | |
| 508 | + return; | |
| 509 | + } | |
| 510 | + const validRows = rows.value.filter((r) => | |
| 511 | + r.partnerId.trim() || | |
| 512 | + r.regionIds.length || | |
| 513 | + r.productCategoryId.trim() || | |
| 514 | + r.productId.trim() || | |
| 515 | + r.labelName.trim() || | |
| 516 | + r.locationId.trim() || | |
| 517 | + r.labelCategoryId.trim() || | |
| 518 | + r.labelTypeId.trim(), | |
| 519 | + ); | |
| 520 | + if (validRows.length === 0) { | |
| 521 | + message.error('Add at least one row.'); | |
| 522 | + return; | |
| 523 | + } | |
| 524 | + for (const [i, row] of validRows.entries()) { | |
| 525 | + if ( | |
| 526 | + !row.partnerId.trim() || | |
| 527 | + row.regionIds.length === 0 || | |
| 528 | + !row.productCategoryId.trim() || | |
| 529 | + !row.productId.trim() || | |
| 530 | + !row.labelName.trim() || | |
| 531 | + !row.locationId.trim() || | |
| 532 | + !row.labelCategoryId.trim() || | |
| 533 | + !row.labelTypeId.trim() | |
| 534 | + ) { | |
| 535 | + message.error(`Row ${i + 1}: fill all required label fields.`); | |
| 536 | + return; | |
| 537 | + } | |
| 538 | + } | |
| 539 | + | |
| 540 | + saving.value = true; | |
| 541 | + try { | |
| 542 | + for (const row of validRows) { | |
| 543 | + await labelAdd({ | |
| 544 | + labelName: row.labelName.trim(), | |
| 545 | + templateCode: templateCode.value, | |
| 546 | + locationId: row.locationId.trim(), | |
| 547 | + labelCategoryId: row.labelCategoryId.trim(), | |
| 548 | + labelTypeId: row.labelTypeId.trim(), | |
| 549 | + productIds: [row.productId.trim()], | |
| 550 | + labelInfoJson: null, | |
| 551 | + state: row.state !== false, | |
| 552 | + }); | |
| 553 | + } | |
| 554 | + await saveTemplateDefaultsForRows(validRows); | |
| 555 | + message.success(`${validRows.length} label(s) created.`); | |
| 556 | + goBack(); | |
| 557 | + } catch { | |
| 558 | + message.error('Save failed.'); | |
| 559 | + } finally { | |
| 560 | + saving.value = false; | |
| 561 | + } | |
| 562 | +} | |
| 563 | + | |
| 564 | +async function saveTemplateDefaultsForRows(validRows: TemplateDataEntryRow[]) { | |
| 565 | + if (!templateDto.value) return; | |
| 566 | + const fullElements = sortedTemplateElements.value; | |
| 567 | + const defaultsMap = new Map<string, { | |
| 568 | + productId: string; | |
| 569 | + labelTypeId: string; | |
| 570 | + defaultValues: Record<string, string>; | |
| 571 | + orderNum: number; | |
| 572 | + }>(); | |
| 573 | + for (const row of templateDto.value.templateProductDefaults ?? []) { | |
| 574 | + defaultsMap.set(`${row.productId}::${row.labelTypeId}`, { | |
| 575 | + productId: row.productId, | |
| 576 | + labelTypeId: row.labelTypeId, | |
| 577 | + defaultValues: { ...row.defaultValues }, | |
| 578 | + orderNum: row.orderNum ?? defaultsMap.size + 1, | |
| 579 | + }); | |
| 580 | + } | |
| 581 | + for (const r of validRows) { | |
| 582 | + const folded = foldNutritionCompositeKeysIntoDefaults(r.fieldValues, fullElements); | |
| 583 | + const defaultValues: Record<string, string> = {}; | |
| 584 | + for (const col of dataColumns.value) { | |
| 585 | + if (col.kind === 'element') { | |
| 586 | + defaultValues[col.el.id] = normalizeDateTimeFieldForSave(col.el, folded[col.el.id] ?? ''); | |
| 587 | + } | |
| 588 | + } | |
| 589 | + for (const el of fullElements) { | |
| 590 | + if (canonicalElementType(el.type) === 'NUTRITION') { | |
| 591 | + const j = folded[el.id]; | |
| 592 | + if (j) defaultValues[el.id] = j; | |
| 593 | + } | |
| 594 | + } | |
| 595 | + const product = products.value.find((p) => p.id === r.productId.trim()); | |
| 596 | + const labelType = labelTypes.value.find((t) => t.id === r.labelTypeId.trim()); | |
| 597 | + Object.assign(defaultValues, buildTemplateBarcodeQrDefaultsFromCodeValue(fullElements, product?.codeValue)); | |
| 598 | + Object.assign( | |
| 599 | + defaultValues, | |
| 600 | + buildTemplateLabelBindingDefaults(fullElements, { | |
| 601 | + labelTypeName: labelType?.typeName ?? labelType?.typeCode, | |
| 602 | + }), | |
| 603 | + ); | |
| 604 | + defaultsMap.set(`${r.productId.trim()}::${r.labelTypeId.trim()}`, { | |
| 605 | + productId: r.productId.trim(), | |
| 606 | + labelTypeId: r.labelTypeId.trim(), | |
| 607 | + defaultValues, | |
| 608 | + orderNum: defaultsMap.size + 1, | |
| 609 | + }); | |
| 610 | + } | |
| 611 | + const tpl = templateDto.value; | |
| 612 | + const loc = appliedLocationToEditor(tpl); | |
| 613 | + const mergedDefaults = Array.from(defaultsMap.values()).map((row, idx) => ({ | |
| 614 | + ...row, | |
| 615 | + orderNum: idx + 1, | |
| 616 | + })); | |
| 617 | + await labelTemplateUpdate(templateCode.value, { | |
| 618 | + id: tpl.id, | |
| 619 | + name: (tpl.name ?? tpl.templateName ?? '').trim() || templateCode.value, | |
| 620 | + labelType: tpl.labelType ?? 'PRICE', | |
| 621 | + unit: tpl.unit ?? 'cm', | |
| 622 | + width: Number(tpl.width ?? 6), | |
| 623 | + height: Number(tpl.height ?? 4), | |
| 624 | + appliedLocation: loc, | |
| 625 | + appliedLocationIds: loc === 'ALL' ? [] : (tpl.appliedLocationIds ?? []), | |
| 626 | + showRuler: tpl.showRuler ?? true, | |
| 627 | + showGrid: tpl.showGrid ?? true, | |
| 628 | + border: tpl.border ?? 'none', | |
| 629 | + printOrientation: tpl.printOrientation ?? 'vertical', | |
| 630 | + state: tpl.state ?? true, | |
| 631 | + elements: labelElementsToApiPayload(fullElements), | |
| 632 | + contents: buildTemplateContentsFromElements(fullElements), | |
| 633 | + templateProductDefaults: mergedDefaults, | |
| 634 | + }); | |
| 635 | +} | |
| 636 | + | |
| 322 | 637 | function goBack() { |
| 323 | 638 | if (fromLabelsBulk.value) { |
| 324 | 639 | void router.push('/labels'); |
| ... | ... | @@ -336,7 +651,9 @@ function goBack() { |
| 336 | 651 | Back |
| 337 | 652 | </a-button> |
| 338 | 653 | <div class="min-w-[200px] flex-1"> |
| 339 | - <div class="text-xs font-medium uppercase tracking-wide text-gray-500">Label template</div> | |
| 654 | + <div class="text-xs font-medium uppercase tracking-wide text-gray-500"> | |
| 655 | + {{ fromLabelsBulk ? 'Bulk Add Labels' : 'Label template' }} | |
| 656 | + </div> | |
| 340 | 657 | <h2 class="truncate text-lg font-semibold text-gray-900">{{ templateTitle }}</h2> |
| 341 | 658 | </div> |
| 342 | 659 | <div class="flex items-center gap-2"> |
| ... | ... | @@ -351,7 +668,12 @@ function goBack() { |
| 351 | 668 | <p v-if="contextHint" class="mb-3 shrink-0 text-sm font-medium text-green-700"> |
| 352 | 669 | {{ contextHint }} |
| 353 | 670 | </p> |
| 354 | - <p class="mb-3 shrink-0 text-sm text-gray-600"> | |
| 671 | + <p v-if="fromLabelsBulk" class="mb-3 shrink-0 text-sm text-gray-600"> | |
| 672 | + Fill label base fields per row, then complete the template input columns. Labels are created with the selected | |
| 673 | + template, and template input values are saved as | |
| 674 | + <span class="font-medium">templateProductDefaults</span>. | |
| 675 | + </p> | |
| 676 | + <p v-else class="mb-3 shrink-0 text-sm text-gray-600"> | |
| 355 | 677 | Bind product and label type per row. Values are saved as |
| 356 | 678 | <span class="font-medium">templateProductDefaults</span>. Nutrition columns fold into JSON under the nutrition element id. |
| 357 | 679 | </p> |
| ... | ... | @@ -362,17 +684,7 @@ function goBack() { |
| 362 | 684 | |
| 363 | 685 | <div v-else class="min-h-0 flex-1 overflow-auto rounded-md border bg-white shadow-sm"> |
| 364 | 686 | <Table |
| 365 | - :columns="[ | |
| 366 | - { title: 'Product', key: 'product', width: 200, fixed: 'left' }, | |
| 367 | - { title: 'Label type', key: 'labelType', width: 180, fixed: 'left' }, | |
| 368 | - ...dataColumns.map((col) => ({ | |
| 369 | - title: columnTitle(col), | |
| 370 | - key: columnKey(col), | |
| 371 | - width: | |
| 372 | - col.kind === 'element' && dataEntryUsesImageUpload(col.el) ? 120 : 140, | |
| 373 | - })), | |
| 374 | - { title: '', key: 'action', width: 72, fixed: 'right' }, | |
| 375 | - ]" | |
| 687 | + :columns="tableColumns" | |
| 376 | 688 | :data-source="rows" |
| 377 | 689 | :pagination="false" |
| 378 | 690 | :row-key="(r: TemplateDataEntryRow) => r.id" |
| ... | ... | @@ -380,17 +692,85 @@ function goBack() { |
| 380 | 692 | size="small" |
| 381 | 693 | > |
| 382 | 694 | <template #bodyCell="{ column, record }"> |
| 383 | - <template v-if="column.key === 'product'"> | |
| 695 | + <template v-if="column.key === 'partner'"> | |
| 384 | 696 | <Select |
| 385 | 697 | show-search |
| 386 | 698 | class="w-full min-w-[160px]" |
| 387 | - :options="productOptions" | |
| 699 | + :options="partnerOptions" | |
| 700 | + :value="record.partnerId || undefined" | |
| 701 | + placeholder="Select company" | |
| 702 | + option-filter-prop="label" | |
| 703 | + @update:value="(v) => applyPartnerToRow(record.id, String(v ?? ''))" | |
| 704 | + /> | |
| 705 | + </template> | |
| 706 | + <template v-else-if="column.key === 'region'"> | |
| 707 | + <Select | |
| 708 | + mode="multiple" | |
| 709 | + show-search | |
| 710 | + class="w-full min-w-[180px]" | |
| 711 | + :disabled="!record.partnerId" | |
| 712 | + :options="groupOptionsForRow(record)" | |
| 713 | + :value="record.regionIds" | |
| 714 | + placeholder="Select region(s)" | |
| 715 | + option-filter-prop="label" | |
| 716 | + @update:value="(v) => applyRegionsToRow(record.id, Array.isArray(v) ? v.map(String) : [])" | |
| 717 | + /> | |
| 718 | + </template> | |
| 719 | + <template v-else-if="column.key === 'productCategory'"> | |
| 720 | + <Select | |
| 721 | + show-search | |
| 722 | + class="w-full min-w-[160px]" | |
| 723 | + :disabled="fromLabelsBulk && (!record.partnerId || record.regionIds.length === 0)" | |
| 724 | + :options="categoryOptionsForRow(record)" | |
| 725 | + :value="record.productCategoryId || undefined" | |
| 726 | + placeholder="Select category" | |
| 727 | + option-filter-prop="label" | |
| 728 | + @update:value="(v) => applyProductCategoryToRow(record.id, String(v ?? ''))" | |
| 729 | + /> | |
| 730 | + </template> | |
| 731 | + <template v-else-if="column.key === 'product'"> | |
| 732 | + <Select | |
| 733 | + show-search | |
| 734 | + class="w-full min-w-[160px]" | |
| 735 | + :disabled="fromLabelsBulk && !record.productCategoryId" | |
| 736 | + :options="fromLabelsBulk ? productOptionsForRow(record) : productOptions" | |
| 388 | 737 | :value="record.productId || undefined" |
| 389 | 738 | placeholder="Select product" |
| 390 | 739 | option-filter-prop="label" |
| 391 | 740 | @update:value="(v) => applyProductToRow(record.id, String(v ?? ''))" |
| 392 | 741 | /> |
| 393 | 742 | </template> |
| 743 | + <template v-else-if="column.key === 'labelName'"> | |
| 744 | + <Input | |
| 745 | + class="min-w-[180px]" | |
| 746 | + :value="record.labelName" | |
| 747 | + placeholder="e.g. Breakfast label" | |
| 748 | + @update:value="(v) => updateRow(record.id, { labelName: String(v ?? '') })" | |
| 749 | + /> | |
| 750 | + </template> | |
| 751 | + <template v-else-if="column.key === 'location'"> | |
| 752 | + <Select | |
| 753 | + show-search | |
| 754 | + class="w-full min-w-[180px]" | |
| 755 | + :disabled="!record.partnerId || record.regionIds.length === 0" | |
| 756 | + :options="locationOptionsForRow(record)" | |
| 757 | + :value="record.locationId || undefined" | |
| 758 | + placeholder="Select location" | |
| 759 | + option-filter-prop="label" | |
| 760 | + @update:value="(v) => updateRow(record.id, { locationId: String(v ?? '') })" | |
| 761 | + /> | |
| 762 | + </template> | |
| 763 | + <template v-else-if="column.key === 'labelCategory'"> | |
| 764 | + <Select | |
| 765 | + show-search | |
| 766 | + class="w-full min-w-[160px]" | |
| 767 | + :options="labelCategoryOptions" | |
| 768 | + :value="record.labelCategoryId || undefined" | |
| 769 | + placeholder="Select category" | |
| 770 | + option-filter-prop="label" | |
| 771 | + @update:value="(v) => updateRow(record.id, { labelCategoryId: String(v ?? '') })" | |
| 772 | + /> | |
| 773 | + </template> | |
| 394 | 774 | <template v-else-if="column.key === 'labelType'"> |
| 395 | 775 | <Select |
| 396 | 776 | show-search |
| ... | ... | @@ -402,6 +782,12 @@ function goBack() { |
| 402 | 782 | @update:value="(v) => applyLabelTypeToRow(record.id, String(v ?? ''))" |
| 403 | 783 | /> |
| 404 | 784 | </template> |
| 785 | + <template v-else-if="column.key === 'state'"> | |
| 786 | + <Switch | |
| 787 | + :checked="record.state !== false" | |
| 788 | + @update:checked="(v) => updateRow(record.id, { state: Boolean(v) })" | |
| 789 | + /> | |
| 790 | + </template> | |
| 405 | 791 | <template v-else-if="column.key === 'action'"> |
| 406 | 792 | <a-button type="text" danger :disabled="rows.length <= 1" @click="removeRow(record.id)"> |
| 407 | 793 | <DeleteOutlined /> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/editor/canvas-utils.ts
| ... | ... | @@ -142,7 +142,8 @@ export function elementResizeHandlesForBox( |
| 142 | 142 | h: number, |
| 143 | 143 | rotation?: string | null, |
| 144 | 144 | ) { |
| 145 | - const isVertical = readElementRotation({ rotation }) === 'vertical'; | |
| 145 | + const rot = readElementRotation({ rotation }); | |
| 146 | + const isVertical = rot === 'vertical' || rot === 'right'; | |
| 146 | 147 | return ELEMENT_RESIZE_HANDLES.filter((handle) => { |
| 147 | 148 | if (isVertical && (handle.id === 'w' || handle.id === 'e')) { |
| 148 | 149 | return false; | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/editor/element-content.vue
| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | isPrintInputElement, |
| 14 | 14 | readLabelEditorFontFamilyChoice, |
| 15 | 15 | } from '../../../shared/label-template/label-template-model'; |
| 16 | +import { elementRotationDegrees } from '../../../shared/text-element-layout'; | |
| 16 | 17 | import NutritionFactsPanel from './nutrition-facts-panel.vue'; |
| 17 | 18 | import { |
| 18 | 19 | normalizeBarcodeType, |
| ... | ... | @@ -35,7 +36,8 @@ const qrDataUrl = ref(''); |
| 35 | 36 | |
| 36 | 37 | const cfg = computed(() => (props.element.config ?? {}) as Record<string, unknown>); |
| 37 | 38 | const type = computed(() => canonicalElementType(props.element.type)); |
| 38 | -const isVertical = computed(() => props.element.rotation === 'vertical'); | |
| 39 | +const rotationDegrees = computed(() => elementRotationDegrees(props.element)); | |
| 40 | +const isVertical = computed(() => rotationDegrees.value === -90 || rotationDegrees.value === 90); | |
| 39 | 41 | |
| 40 | 42 | const commonStyle = computed((): CSSProperties => ({ |
| 41 | 43 | fontSize: `${(cfg.value.fontSize as number) ?? 14}px`, |
| ... | ... | @@ -50,15 +52,20 @@ const textRotationStyle = computed((): CSSProperties => { |
| 50 | 52 | type.value === 'TEXT_STATIC' || |
| 51 | 53 | type.value === 'TEXT_PRODUCT' || |
| 52 | 54 | type.value === 'TEXT_PRICE'; |
| 53 | - if (isVertical.value && textLike) { | |
| 54 | - return { writingMode: 'vertical-rl', textOrientation: 'mixed' }; | |
| 55 | + if (rotationDegrees.value !== 0 && textLike) { | |
| 56 | + return { | |
| 57 | + display: 'inline-block', | |
| 58 | + transform: `rotate(${rotationDegrees.value}deg)`, | |
| 59 | + transformOrigin: 'center center', | |
| 60 | + whiteSpace: 'nowrap', | |
| 61 | + }; | |
| 55 | 62 | } |
| 56 | 63 | return {}; |
| 57 | 64 | }); |
| 58 | 65 | |
| 59 | 66 | const rotateBoxStyle = computed((): CSSProperties => |
| 60 | - isVertical.value | |
| 61 | - ? { transform: 'rotate(-90deg)', transformOrigin: 'center center' } | |
| 67 | + rotationDegrees.value !== 0 | |
| 68 | + ? { transform: `rotate(${rotationDegrees.value}deg)`, transformOrigin: 'center center' } | |
| 62 | 69 | : {}, |
| 63 | 70 | ); |
| 64 | 71 | |
| ... | ... | @@ -92,10 +99,16 @@ function formatDateByPreset(format: string, date: Date): string { |
| 92 | 99 | const dd = String(date.getDate()).padStart(2, '0'); |
| 93 | 100 | const hh = String(date.getHours()).padStart(2, '0'); |
| 94 | 101 | const min = String(date.getMinutes()).padStart(2, '0'); |
| 102 | + const hour12 = date.getHours() % 12 || 12; | |
| 103 | + const meridiem = date.getHours() >= 12 ? 'pm' : 'am'; | |
| 95 | 104 | const monthLong = date.toLocaleString('en-US', { month: 'long' }).toUpperCase(); |
| 96 | 105 | const dayLong = date.toLocaleString('en-US', { weekday: 'long' }).toUpperCase(); |
| 97 | 106 | const dayShort = date.toLocaleString('en-US', { weekday: 'short' }).toUpperCase(); |
| 98 | 107 | switch (format) { |
| 108 | + case '12 hr': | |
| 109 | + return `${hour12}:${min}${meridiem}`; | |
| 110 | + case '24 hr': | |
| 111 | + return `${hh}:${min}`; | |
| 99 | 112 | case 'DD/MM/YYYY': |
| 100 | 113 | return `${dd}/${mm}/${yyyy}`; |
| 101 | 114 | case 'MM/DD/YYYY': |
| ... | ... | @@ -245,6 +258,12 @@ const durationExample = computed(() => { |
| 245 | 258 | return `${durationValue} ${rawFormat}`; |
| 246 | 259 | }); |
| 247 | 260 | |
| 261 | +const timeExample = computed(() => { | |
| 262 | + const timeFormatRaw = String(cfg.value.format ?? cfg.value.Format ?? '24 hr').trim(); | |
| 263 | + const timeFormat = timeFormatRaw === '12 hr' ? '12 hr' : '24 hr'; | |
| 264 | + return formatDateByPreset(timeFormat, new Date()); | |
| 265 | +}); | |
| 266 | + | |
| 248 | 267 | const weightDisplay = computed(() => { |
| 249 | 268 | const rawV = cfg.value.value ?? cfg.value.Value; |
| 250 | 269 | const numVal = |
| ... | ... | @@ -395,7 +414,7 @@ const nutritionFontFamily = computed(() => readLabelEditorFontFamilyChoice(cfg.v |
| 395 | 414 | <!-- TIME --> |
| 396 | 415 | <div v-else-if="type === 'TIME'" class="flex h-full w-full items-center justify-center overflow-hidden"> |
| 397 | 416 | <div class="overflow-hidden whitespace-nowrap px-1" :style="{ ...commonStyle, ...rotateBoxStyle }"> |
| 398 | - {{ typeof cfg.__previewFormatted === 'string' ? String(cfg.__previewFormatted).trim() || '—' : '12:30' }} | |
| 417 | + {{ typeof cfg.__previewFormatted === 'string' ? String(cfg.__previewFormatted).trim() || '—' : timeExample }} | |
| 399 | 418 | </div> |
| 400 | 419 | </div> |
| 401 | 420 | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/labeling/label-templates/editor/properties-panel.vue
| ... | ... | @@ -12,6 +12,7 @@ import { computed, onMounted, ref, watch } from 'vue'; |
| 12 | 12 | import { $t } from '@vben/locales'; |
| 13 | 13 | |
| 14 | 14 | import { Checkbox, Input, Radio, Select, Switch } from 'ant-design-vue'; |
| 15 | +import { RotateLeftOutlined, RotateRightOutlined } from '@ant-design/icons-vue'; | |
| 15 | 16 | |
| 16 | 17 | import { ImageUpload } from '#/components/upload'; |
| 17 | 18 | |
| ... | ... | @@ -41,10 +42,13 @@ import { |
| 41 | 42 | import { readImageScaleMode } from '../../../shared/image-scale-mode'; |
| 42 | 43 | import { readInvertColors, isTextLikeElementForInvertColors } from '../../../shared/invert-colors'; |
| 43 | 44 | import { |
| 45 | + elementRotationDegrees, | |
| 44 | 46 | readFontStyle, |
| 45 | 47 | readFontWeight, |
| 46 | 48 | readTextDecoration, |
| 47 | 49 | readVerticalAlign, |
| 50 | + rotateElementLeftValue, | |
| 51 | + rotateElementRightValue, | |
| 48 | 52 | } from '../../../shared/text-element-layout'; |
| 49 | 53 | import { |
| 50 | 54 | displayLengthToElementPx, |
| ... | ... | @@ -386,20 +390,26 @@ function onImageUploadChange(ossId: string | string[]) { |
| 386 | 390 | |
| 387 | 391 | <template v-if="!isBlank"> |
| 388 | 392 | <div> |
| 389 | - <label class="text-xs text-gray-600">{{ $t('foodLabeling.labelTemplates.rotation') }}</label> | |
| 390 | - <Radio.Group | |
| 391 | - class="fl-prop-radio-group mt-1" | |
| 392 | - button-style="solid" | |
| 393 | - :value="selectedElement.rotation" | |
| 394 | - @update:value="(v: Rotation) => patchElement(selectedElement!.id, { rotation: v })" | |
| 395 | - > | |
| 396 | - <Radio.Button value="horizontal"> | |
| 397 | - {{ $t('foodLabeling.labelTemplates.horizontal') }} | |
| 398 | - </Radio.Button> | |
| 399 | - <Radio.Button value="vertical"> | |
| 400 | - {{ $t('foodLabeling.labelTemplates.vertical') }} | |
| 401 | - </Radio.Button> | |
| 402 | - </Radio.Group> | |
| 393 | + <label class="text-xs text-gray-600">Rotate</label> | |
| 394 | + <div class="mt-1 flex items-center gap-2"> | |
| 395 | + <a-button | |
| 396 | + size="small" | |
| 397 | + :disabled="positionLocked" | |
| 398 | + @click="patchElement(selectedElement!.id, { rotation: rotateElementLeftValue(selectedElement!) as Rotation })" | |
| 399 | + > | |
| 400 | + <template #icon><RotateLeftOutlined /></template> | |
| 401 | + Left | |
| 402 | + </a-button> | |
| 403 | + <a-button | |
| 404 | + size="small" | |
| 405 | + :disabled="positionLocked" | |
| 406 | + @click="patchElement(selectedElement!.id, { rotation: rotateElementRightValue(selectedElement!) as Rotation })" | |
| 407 | + > | |
| 408 | + <template #icon><RotateRightOutlined /></template> | |
| 409 | + Right | |
| 410 | + </a-button> | |
| 411 | + <span class="text-xs text-gray-500">{{ elementRotationDegrees(selectedElement) }}°</span> | |
| 412 | + </div> | |
| 403 | 413 | </div> |
| 404 | 414 | |
| 405 | 415 | <div> |
| ... | ... | @@ -744,6 +754,25 @@ function onImageUploadChange(ossId: string | string[]) { |
| 744 | 754 | </div> |
| 745 | 755 | </template> |
| 746 | 756 | |
| 757 | + <template v-else-if="canonicalElementType(selectedElement.type) === 'TIME'"> | |
| 758 | + <div> | |
| 759 | + <label class="text-xs text-gray-600">Time Format</label> | |
| 760 | + <Select | |
| 761 | + size="small" | |
| 762 | + class="mt-1 w-full" | |
| 763 | + :value=" | |
| 764 | + cfgPickStr(selectedElement!.config as Record<string, unknown>, ['format', 'Format'], '24 hr') === '12 hr' | |
| 765 | + ? '12 hr' | |
| 766 | + : '24 hr' | |
| 767 | + " | |
| 768 | + @update:value="(v) => patchConfig(selectedElement!.id, selectedElement!, { format: v })" | |
| 769 | + > | |
| 770 | + <Select.Option value="12 hr">12 hr - 2:00pm</Select.Option> | |
| 771 | + <Select.Option value="24 hr">24 hr - 14:00</Select.Option> | |
| 772 | + </Select> | |
| 773 | + </div> | |
| 774 | + </template> | |
| 775 | + | |
| 747 | 776 | <template v-else-if="canonicalElementType(selectedElement.type) === 'DURATION'"> |
| 748 | 777 | <div> |
| 749 | 778 | <label class="text-xs text-gray-600">Format</label> | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/label-form-live-preview.ts
| ... | ... | @@ -50,10 +50,16 @@ export function formatDateByPreset(format: string, date: Date): string { |
| 50 | 50 | const dd = String(date.getDate()).padStart(2, '0'); |
| 51 | 51 | const hh = String(date.getHours()).padStart(2, '0'); |
| 52 | 52 | const min = String(date.getMinutes()).padStart(2, '0'); |
| 53 | + const hour12 = date.getHours() % 12 || 12; | |
| 54 | + const meridiem = date.getHours() >= 12 ? 'pm' : 'am'; | |
| 53 | 55 | const monthLong = date.toLocaleString('en-US', { month: 'long' }).toUpperCase(); |
| 54 | 56 | const dayLong = date.toLocaleString('en-US', { weekday: 'long' }).toUpperCase(); |
| 55 | 57 | const dayShort = date.toLocaleString('en-US', { weekday: 'short' }).toUpperCase(); |
| 56 | 58 | switch (format) { |
| 59 | + case '12 hr': | |
| 60 | + return `${hour12}:${min}${meridiem}`; | |
| 61 | + case '24 hr': | |
| 62 | + return `${hh}:${min}`; | |
| 57 | 63 | case 'DD/MM/YYYY': |
| 58 | 64 | return `${dd}/${mm}/${yyyy}`; |
| 59 | 65 | case 'MM/DD/YYYY': |
| ... | ... | @@ -113,7 +119,10 @@ function applyElementPrefix(cfg: Record<string, unknown>, body: string): string |
| 113 | 119 | |
| 114 | 120 | function dateFormatPatternForElement(el: LabelElement, cfg: Record<string, unknown>): string { |
| 115 | 121 | const type = canonicalElementType(el.type); |
| 116 | - if (type === 'TIME') return 'HH:mm'; | |
| 122 | + if (type === 'TIME') { | |
| 123 | + const raw = String(cfg.format ?? cfg.Format ?? '24 hr').trim(); | |
| 124 | + return raw === '12 hr' ? '12 hr' : '24 hr'; | |
| 125 | + } | |
| 117 | 126 | const it = String(cfg.inputType ?? cfg.InputType ?? '').toLowerCase(); |
| 118 | 127 | const raw = |
| 119 | 128 | (typeof cfg.format === 'string' && cfg.format.trim() |
| ... | ... | @@ -172,7 +181,7 @@ function resolveFromOffsetAmount( |
| 172 | 181 | return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d)); |
| 173 | 182 | } |
| 174 | 183 | if (type === 'TIME') { |
| 175 | - return applyElementPrefix(cfg, formatDateByPreset('HH:mm', d)); | |
| 184 | + return applyElementPrefix(cfg, formatDateByPreset(dateFormatPatternForElement(el, cfg), d)); | |
| 176 | 185 | } |
| 177 | 186 | return applyElementPrefix(cfg, `${amount} ${unit}`.trim()); |
| 178 | 187 | } | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/label-template/label-template-model.ts
| ... | ... | @@ -7,7 +7,7 @@ export type Unit = 'cm' | 'inch'; |
| 7 | 7 | /** 与接口文档一致:ALL=全部门店,SPECIFIED=指定门店(需 appliedLocationIds) */ |
| 8 | 8 | export type AppliedLocation = 'ALL' | 'SPECIFIED'; |
| 9 | 9 | export type AppliedScopeType = 'ALL' | 'SPECIFIED'; |
| 10 | -export type Rotation = 'horizontal' | 'vertical'; | |
| 10 | +export type Rotation = 'horizontal' | 'vertical' | 'left' | 'right' | 'rotate180'; | |
| 11 | 11 | export type PrintOrientation = 'horizontal' | 'vertical'; |
| 12 | 12 | export type Border = 'none' | 'line' | 'dotted'; |
| 13 | 13 | |
| ... | ... | @@ -399,7 +399,7 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl |
| 399 | 399 | height: 24, |
| 400 | 400 | config: { format: 'DD/MM/YYYY', offsetDays: 0, fontSize: 14, textAlign: 'left' }, |
| 401 | 401 | }, |
| 402 | - TIME: { width: 100, height: 24, config: { format: 'HH:mm', offsetDays: 0 } }, | |
| 402 | + TIME: { width: 100, height: 24, config: { format: '24 hr', offsetDays: 0, fontSize: 14, textAlign: 'left' } }, | |
| 403 | 403 | DURATION: { |
| 404 | 404 | width: 120, |
| 405 | 405 | height: 24, | ... | ... |
泰额版/Food Labeling Management Code/Yi.Vben5.Vue3/apps/web-antd/src/views/food-labeling/shared/text-element-layout.ts
| ... | ... | @@ -38,25 +38,61 @@ export function readTextDecoration( |
| 38 | 38 | /** 读取元素 rotation(兼容大小写 / 旧数据) */ |
| 39 | 39 | export function readElementRotation( |
| 40 | 40 | el: { rotation?: string | null }, |
| 41 | -): 'horizontal' | 'vertical' { | |
| 41 | +): 'horizontal' | 'vertical' | 'right' | 'rotate180' { | |
| 42 | 42 | const v = String(el.rotation ?? 'horizontal').trim().toLowerCase(); |
| 43 | - return v === 'vertical' ? 'vertical' : 'horizontal'; | |
| 43 | + if (v === 'vertical' || v === 'left' || v === 'rotate-left' || v === 'left90') return 'vertical'; | |
| 44 | + if (v === 'right' || v === 'rotate-right' || v === 'right90') return 'right'; | |
| 45 | + if (v === 'rotate180' || v === '180' || v === 'down' || v === 'inverted') return 'rotate180'; | |
| 46 | + return 'horizontal'; | |
| 47 | +} | |
| 48 | + | |
| 49 | +export function elementRotationDegrees( | |
| 50 | + el: { rotation?: string | null }, | |
| 51 | +): 0 | -90 | 90 | 180 { | |
| 52 | + const rot = readElementRotation(el); | |
| 53 | + if (rot === 'vertical') return -90; | |
| 54 | + if (rot === 'right') return 90; | |
| 55 | + if (rot === 'rotate180') return 180; | |
| 56 | + return 0; | |
| 57 | +} | |
| 58 | + | |
| 59 | +export function rotateElementLeftValue( | |
| 60 | + el: { rotation?: string | null }, | |
| 61 | +): 'horizontal' | 'vertical' | 'right' | 'rotate180' { | |
| 62 | + const current = elementRotationDegrees(el); | |
| 63 | + const next = (((current - 90) % 360) + 360) % 360; | |
| 64 | + if (next === 270) return 'vertical'; | |
| 65 | + if (next === 180) return 'rotate180'; | |
| 66 | + if (next === 90) return 'right'; | |
| 67 | + return 'horizontal'; | |
| 68 | +} | |
| 69 | + | |
| 70 | +export function rotateElementRightValue( | |
| 71 | + el: { rotation?: string | null }, | |
| 72 | +): 'horizontal' | 'vertical' | 'right' | 'rotate180' { | |
| 73 | + const current = elementRotationDegrees(el); | |
| 74 | + const next = (((current + 90) % 360) + 360) % 360; | |
| 75 | + if (next === 270) return 'vertical'; | |
| 76 | + if (next === 180) return 'rotate180'; | |
| 77 | + if (next === 90) return 'right'; | |
| 78 | + return 'horizontal'; | |
| 44 | 79 | } |
| 45 | 80 | |
| 46 | 81 | /** 切换 horizontal / vertical 时交换宽高并保持中心点不变 */ |
| 47 | 82 | export function patchElementRotationWithLayout( |
| 48 | 83 | el: { x: number; y: number; width: number; height: number; rotation?: string | null }, |
| 49 | - nextRotation: 'horizontal' | 'vertical', | |
| 84 | + nextRotation: 'horizontal' | 'vertical' | 'right' | 'rotate180', | |
| 50 | 85 | ): { |
| 51 | - rotation: 'horizontal' | 'vertical'; | |
| 86 | + rotation: 'horizontal' | 'vertical' | 'right' | 'rotate180'; | |
| 52 | 87 | x: number; |
| 53 | 88 | y: number; |
| 54 | 89 | width: number; |
| 55 | 90 | height: number; |
| 56 | 91 | } { |
| 57 | 92 | const prev = readElementRotation(el); |
| 93 | + const shouldSwapBox = (rotation: string) => rotation === 'vertical' || rotation === 'right'; | |
| 58 | 94 | if (prev === nextRotation) { |
| 59 | - if (nextRotation === 'vertical' && el.width > el.height) { | |
| 95 | + if (shouldSwapBox(nextRotation) && el.width > el.height) { | |
| 60 | 96 | const w = Math.max(1, el.width); |
| 61 | 97 | const h = Math.max(1, el.height); |
| 62 | 98 | const cx = el.x + w / 2; |
| ... | ... | @@ -79,6 +115,15 @@ export function patchElementRotationWithLayout( |
| 79 | 115 | height: el.height, |
| 80 | 116 | }; |
| 81 | 117 | } |
| 118 | + if (!shouldSwapBox(prev) && !shouldSwapBox(nextRotation)) { | |
| 119 | + return { | |
| 120 | + rotation: nextRotation, | |
| 121 | + x: el.x, | |
| 122 | + y: el.y, | |
| 123 | + width: el.width, | |
| 124 | + height: el.height, | |
| 125 | + }; | |
| 126 | + } | |
| 82 | 127 | const w = Math.max(1, el.width); |
| 83 | 128 | const h = Math.max(1, el.height); |
| 84 | 129 | const cx = el.x + w / 2; |
| ... | ... | @@ -105,11 +150,12 @@ export function canonicalElementGeometry< |
| 105 | 150 | export function normalizeElementRotationBox< |
| 106 | 151 | T extends { rotation?: string | null; width: number; height: number; x: number; y: number }, |
| 107 | 152 | >(el: T): T { |
| 108 | - if (readElementRotation(el) !== 'vertical') return el; | |
| 153 | + const rotation = readElementRotation(el); | |
| 154 | + if (rotation !== 'vertical' && rotation !== 'right') return el; | |
| 109 | 155 | if (el.width <= el.height) return el; |
| 156 | + const patch = patchElementRotationWithLayout({ ...el, rotation: 'horizontal' }, rotation); | |
| 110 | 157 | return { |
| 111 | 158 | ...el, |
| 112 | - rotation: 'vertical', | |
| 113 | - ...patchElementRotationWithLayout({ ...el, rotation: 'horizontal' }, 'vertical'), | |
| 159 | + ...patch, | |
| 114 | 160 | }; |
| 115 | 161 | } | ... | ... |
美国版/Food Labeling Management App UniApp/.env.development
美国版/Food Labeling Management App UniApp/.env.example
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
| ... | ... | @@ -757,7 +757,6 @@ const handleScan = async () => { |
| 757 | 757 | if (hasPreferredClassicDeviceInList()) { |
| 758 | 758 | isScanning.value = false |
| 759 | 759 | debugInfo.value.lastBleEvent = 'skipped (paired classic device found)' |
| 760 | - uni.showToast({ title: 'Using paired classic Bluetooth devices', icon: 'none' }) | |
| 761 | 760 | return |
| 762 | 761 | } |
| 763 | 762 | |
| ... | ... | @@ -804,6 +803,7 @@ const handleConnect = async (dev: BtDevice) => { |
| 804 | 803 | uni.showToast({ title: 'Connected!', icon: 'success' }) |
| 805 | 804 | } catch (e: any) { |
| 806 | 805 | await refreshNativeDebug() |
| 806 | + refreshCurrentPrinter() | |
| 807 | 807 | errorMsg.value = (e && e.message) ? e.message : 'Connection failed' |
| 808 | 808 | connectingId.value = '' |
| 809 | 809 | } | ... | ... |
美国版/Food Labeling Management App UniApp/src/pages/more/printers.vue
| ... | ... | @@ -449,6 +449,7 @@ const connectBt = async (device: any) => { |
| 449 | 449 | uni.hideLoading() |
| 450 | 450 | uni.showToast({ title: t('printers.connectSuccess') }) |
| 451 | 451 | } catch (e) { |
| 452 | + refreshStatus() | |
| 452 | 453 | uni.hideLoading() |
| 453 | 454 | uni.showToast({ title: t('printers.connectFail'), icon: 'none' }) |
| 454 | 455 | } finally { | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/apiBase.ts
| ... | ... | @@ -3,7 +3,7 @@ |
| 3 | 3 | * 调试本地时请与 .env.development 保持一致;仅作 fallback,优先使用环境变量。 |
| 4 | 4 | */ |
| 5 | 5 | // export const US_BACKEND_ORIGIN_FALLBACK = 'http://flus-test.3ffoodsafety.com' |
| 6 | -export const US_BACKEND_ORIGIN_FALLBACK = 'http://192.168.31.88:19001' | |
| 6 | +export const US_BACKEND_ORIGIN_FALLBACK = 'http://192.168.31.89:19001' | |
| 7 | 7 | |
| 8 | 8 | /** |
| 9 | 9 | * 解析美国版后端根地址(不含末尾 /)。 | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/bluetoothTool.js
| ... | ... | @@ -80,6 +80,24 @@ function isConnectionStateConnected (state) { |
| 80 | 80 | return String(state || '').trim().toLowerCase() === 'connected' |
| 81 | 81 | } |
| 82 | 82 | |
| 83 | +function getClassicBondState (device) { | |
| 84 | + // #ifdef APP-PLUS | |
| 85 | + try { | |
| 86 | + const state = invoke(device, 'getBondState') | |
| 87 | + return typeof state === 'number' ? state : Number(state) | |
| 88 | + } catch (_) { | |
| 89 | + return null | |
| 90 | + } | |
| 91 | + // #endif | |
| 92 | + return null | |
| 93 | +} | |
| 94 | + | |
| 95 | +function isClassicBonded (device) { | |
| 96 | + const state = getClassicBondState(device) | |
| 97 | + if (state == null || Number.isNaN(state)) return true | |
| 98 | + return state === 12 | |
| 99 | +} | |
| 100 | + | |
| 83 | 101 | function runOnUiThread (fn) { |
| 84 | 102 | // #ifdef APP-PLUS |
| 85 | 103 | try { |
| ... | ... | @@ -380,8 +398,9 @@ var blueToothTool = { |
| 380 | 398 | callback && callback(false) |
| 381 | 399 | return false |
| 382 | 400 | } |
| 401 | + let device = null | |
| 383 | 402 | try { |
| 384 | - let device = invoke(btAdapter, 'getRemoteDevice', address) | |
| 403 | + device = invoke(btAdapter, 'getRemoteDevice', address) | |
| 385 | 404 | const candidates = createSocketCandidates(device) |
| 386 | 405 | let socket = null |
| 387 | 406 | let lastError = null |
| ... | ... | @@ -415,6 +434,9 @@ var blueToothTool = { |
| 415 | 434 | |
| 416 | 435 | try { |
| 417 | 436 | invoke(btSocket, 'connect') |
| 437 | + if (!isClassicBonded(device)) { | |
| 438 | + throw new Error('Bluetooth PIN or pairing key is incorrect') | |
| 439 | + } | |
| 418 | 440 | const streamReady = this.readData() |
| 419 | 441 | if (!streamReady) { |
| 420 | 442 | throw new Error(this.state.lastError || 'Bluetooth output stream not ready') | ... | ... |
美国版/Food Labeling Management App UniApp/src/utils/print/manager/printerManager.ts
| ... | ... | @@ -99,14 +99,6 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 99 | 99 | } |
| 100 | 100 | classicBluetooth.connDevice(device.deviceId, (ok: boolean) => { |
| 101 | 101 | if (ok) { |
| 102 | - setBluetoothConnection({ | |
| 103 | - deviceId: device.deviceId, | |
| 104 | - deviceName: device.name || 'Bluetooth Printer', | |
| 105 | - deviceType: 'classic', | |
| 106 | - transportMode: 'generic', | |
| 107 | - driverKey: driver.key, | |
| 108 | - mtu: driver.preferredBleMtu || 20, | |
| 109 | - }) | |
| 110 | 102 | /** |
| 111 | 103 | * connDevice 回调 ok 可能早于 socket/outputStream 就绪; |
| 112 | 104 | * 若立刻测试打印,会出现 state=idle、outputReady=false。 |
| ... | ... | @@ -118,10 +110,19 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 118 | 110 | : null |
| 119 | 111 | const ready = !!st?.outputReady && (!!st?.socketConnected || String(st?.connectionState || '').toLowerCase() === 'connected') |
| 120 | 112 | if (ready) { |
| 113 | + setBluetoothConnection({ | |
| 114 | + deviceId: device.deviceId, | |
| 115 | + deviceName: device.name || 'Bluetooth Printer', | |
| 116 | + deviceType: 'classic', | |
| 117 | + transportMode: 'generic', | |
| 118 | + driverKey: driver.key, | |
| 119 | + mtu: driver.preferredBleMtu || 20, | |
| 120 | + }) | |
| 121 | 121 | resolve() |
| 122 | 122 | return |
| 123 | 123 | } |
| 124 | 124 | if (Date.now() - start > 2500) { |
| 125 | + clearPrinter() | |
| 125 | 126 | reject(new Error('Classic Bluetooth connection is not ready')) |
| 126 | 127 | return |
| 127 | 128 | } |
| ... | ... | @@ -133,9 +134,11 @@ function connectClassicBluetooth (device: PrinterCandidate, driver: PrinterDrive |
| 133 | 134 | const message = typeof classicBluetooth.getLastError === 'function' |
| 134 | 135 | ? classicBluetooth.getLastError() |
| 135 | 136 | : '' |
| 137 | + clearPrinter() | |
| 136 | 138 | reject(new Error(message || 'Classic Bluetooth connection failed.')) |
| 137 | 139 | }) |
| 138 | 140 | } catch (error: any) { |
| 141 | + clearPrinter() | |
| 139 | 142 | reject(error instanceof Error ? error : new Error(String(error || 'Classic Bluetooth connection failed.'))) |
| 140 | 143 | } |
| 141 | 144 | } |
| ... | ... | @@ -244,21 +247,26 @@ function connectBlePrinter (device: PrinterCandidate, driver: PrinterDriver): Pr |
| 244 | 247 | |
| 245 | 248 | export async function connectBluetoothPrinter (device: PrinterCandidate): Promise<PrinterDriver> { |
| 246 | 249 | const driver = resolvePrinterDriver(device) |
| 247 | - if (driver.key === 'gp-d320fx') { | |
| 248 | - try { | |
| 249 | - await connectBlePrinter(device, driver) | |
| 250 | - } catch (_) { | |
| 250 | + try { | |
| 251 | + if (driver.key === 'gp-d320fx') { | |
| 252 | + try { | |
| 253 | + await connectBlePrinter(device, driver) | |
| 254 | + } catch (_) { | |
| 255 | + await connectClassicBluetooth(device, driver) | |
| 256 | + } | |
| 257 | + return driver | |
| 258 | + } | |
| 259 | + const resolvedType = driver.resolveConnectionType(device) | |
| 260 | + if (resolvedType === 'classic') { | |
| 251 | 261 | await connectClassicBluetooth(device, driver) |
| 262 | + } else { | |
| 263 | + await connectBlePrinter(device, driver) | |
| 252 | 264 | } |
| 253 | 265 | return driver |
| 266 | + } catch (error) { | |
| 267 | + clearPrinter() | |
| 268 | + throw error | |
| 254 | 269 | } |
| 255 | - const resolvedType = driver.resolveConnectionType(device) | |
| 256 | - if (resolvedType === 'classic') { | |
| 257 | - await connectClassicBluetooth(device, driver) | |
| 258 | - } else { | |
| 259 | - await connectBlePrinter(device, driver) | |
| 260 | - } | |
| 261 | - return driver | |
| 262 | 270 | } |
| 263 | 271 | |
| 264 | 272 | export function useBuiltinPrinter (driverKey = 'generic-tsc') { | ... | ... |
美国版/Food Labeling Management Platform/.env.local
美国版/Food Labeling Management Platform/build/assets/index-CCZCnrrc.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-9j82-SgZ.js"></script> | |
| 8 | + <script type="module" crossorigin src="/assets/index-CCZCnrrc.js"></script> | |
| 9 | 9 | <link rel="stylesheet" crossorigin href="/assets/index-C6CSIunP.css"> |
| 10 | 10 | </head> |
| 11 | 11 | ... | ... |