Commit 5a192b24bd4eda6fe9b9048fea77a2e5ecd83812

Authored by 杨鑫
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
  1 +# H5 / App 开发:直连本地后端(按本机 IP 与端口修改)
  2 +VITE_US_API_BASE=http://192.168.31.89:19001
... ...
泰额版/Food Labeling Management App UniApp/.env.example 0 → 100644
  1 +# 复制为 .env.development(开发)或 .env.production(打包),按环境填写后端根地址(不含末尾 /)
  2 +VITE_US_API_BASE=http://192.168.31.89:19001
... ...
泰额版/Food Labeling Management App UniApp/.env.production 0 → 100644
  1 +# App / H5 正式打包:直连生产/测试服务器(打包前按部署环境修改)
  2 +VITE_US_API_BASE=http://flus-test.3ffoodsafety.com
... ...
泰额版/Food Labeling Management App UniApp/.hbuilderx/launch.json
... ... @@ -5,6 +5,11 @@
5 5 "customPlaygroundType" : "local",
6 6 "playground" : "custom",
7 7 "type" : "uni-app:app-android"
  8 + },
  9 + {
  10 + "customPlaygroundType" : "device",
  11 + "playground" : "standard",
  12 + "type" : "uni-app:app-ios"
8 13 }
9 14 ]
10 15 }
... ...
泰额版/Food Labeling Management App UniApp/src/App.vue
... ... @@ -3,11 +3,13 @@ import { onLaunch, onShow, onHide } from &quot;@dcloudio/uni-app&quot;;
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 &#39;vue&#39;
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(() =&gt; ({}))
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
1 1 /// <reference types="vite/client" />
2 2  
3 3 interface ImportMetaEnv {
4   - /** 美国版后端根地址,如 http://192.168.1.2:19001;H5 开发可留空走 Vite /api 代理 */
  4 + /** 美国版后端根地址,如 http://192.168.1.2:19001;在 .env.development / .env.production 中配置 */
5 5 readonly VITE_US_API_BASE?: string
6 6 }
7 7  
... ...
泰额版/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(() =&gt; {
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: &#39;bluetooth&#39; | &#39;builtin&#39;) {
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 = () =&gt; {
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 () =&gt; {
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 () =&gt; {
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) =&gt; {
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 () =&gt; {
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(() =&gt; {
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, () =&gt; {
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&lt;string, string&gt; = {
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&lt;string, string&gt; = {
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&lt;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&lt;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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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(&#39;&#39;)
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(&#39;&#39;)
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&lt;void&gt; {
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(() =&gt; {
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&lt;string, string | undefined&gt;) =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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 = () =&gt; {
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) =&gt; {
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&lt;string, unknown&gt;): Record&lt;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&lt;string, any&gt;): 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&lt;void&gt; {
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&lt;void&gt; {
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&lt;void&gt; {
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&lt;number&gt;
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 = &#39;printerDebugLog&#39; // &#39;1&#39; | &#39;0&#39;
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&lt;void&gt; {
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&lt;void&gt; {
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&lt;void&gt; {
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&lt;string, any&gt;, 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&lt;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&lt;T&gt;(
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 &#39;@vben/common-ui&#39;;
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 &#39;./nutrition-manual-field-control.vue&#39;;
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[] =&gt; {
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(() =&gt;
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(() =&gt;
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(() =&gt; {
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(&#39;&#39;);
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 =&gt; {
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(() =&gt; {
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(() =&gt; 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 &#39;vue&#39;;
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) }}&deg;</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&lt;string, unknown&gt;, 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 = &#39;cm&#39; | &#39;inch&#39;;
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&lt;
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
1 1 # H5 / App 开发:直连本地后端(按本机 IP 与端口修改)
2   -VITE_US_API_BASE=http://192.168.31.88:19001
  2 +VITE_US_API_BASE=http://192.168.31.89:19001
... ...
美国版/Food Labeling Management App UniApp/.env.example
1 1 # 复制为 .env.development(开发)或 .env.production(打包),按环境填写后端根地址(不含末尾 /)
2   -VITE_US_API_BASE=http://192.168.31.88:19001
  2 +VITE_US_API_BASE=http://192.168.31.89:19001
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/bluetooth.vue
... ... @@ -757,7 +757,6 @@ const handleScan = async () =&gt; {
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) =&gt; {
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) =&gt; {
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
1   - VITE_API_BASE_URL=http://192.168.31.88:19001
  1 + VITE_API_BASE_URL=http://192.168.31.89:19001
2 2 # VITE_API_BASE_URL=http://flus-test.3ffoodsafety.com
... ...
美国版/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  
... ...