Commit 2055477052aec2e620d1170221f889e7f2b436d3

Authored by 杨鑫
1 parent 9d218930

更新bug

Showing 98 changed files with 4952 additions and 1992 deletions
6-18代码优化.md deleted
1 -# 6-18 代码优化  
2 -  
3 -本文档说明 **2026-06-18** 对 **`GET /api/app/label-template`** 列表接口的第二轮修复。  
4 -  
5 -6-17 已完成 scope 库结构兼容(未迁移 `fl_label_template_partner` / `fl_label_template_region` 时仍可查询),但测试环境 Web **Label Templates** 页仍返回 **500**,Network 中:  
6 -  
7 -`GET /api/app/label-template?SkipCount=1&MaxResultCount=10`  
8 -  
9 -测试环境:`http://flus-test.3ffoodsafety.com`  
10 -  
11 ----  
12 -  
13 -## 一、现象  
14 -  
15 -- 页面提示:**Failed to load label templates. Request failed.**  
16 -- 接口 HTTP **500**,响应体 `errors` 为空,无具体异常文案。  
17 -- 同页 `partner` / `group` / `location` 下拉接口可正常加载。  
18 -  
19 -> `SkipCount=1` 表示**第 1 页**(页码从 1 起),不是报错原因。详见 `Helpers/PagedQueryConvention.cs`。  
20 -  
21 ----  
22 -  
23 -## 二、根因  
24 -  
25 -服务端日志已明确报错:  
26 -  
27 -```text  
28 -Unknown column 'AppliedPartnerType' in 'field list'  
29 -```  
30 -  
31 -对应 SQL:  
32 -  
33 -```sql  
34 -SELECT ... `AppliedLocationType`,`AppliedPartnerType`,`AppliedRegionType`, ...  
35 -FROM `fl_label_template`  
36 -WHERE NOT ( `IsDeleted`=1 )  
37 -ORDER BY IFNULL(`LastModificationTime`,`CreationTime`) DESC  
38 -LIMIT 0,10  
39 -```  
40 -  
41 -### 2.1 主因:ORM 实体仍映射不存在列  
42 -  
43 -6-17 曾在 `FlLabelTemplateDbEntity` 上对 `AppliedPartnerType` / `AppliedRegionType` 标记 `[SugarColumn(IsIgnore = true)]`,但当前 SqlSugar 运行时**仍会**把这两列拼进 `SELECT`(与 `fl_label.AppliedRegionType` 的 `IsIgnore` 行为不一致),导致未执行 `fl_label_template_scope.sql` 的库直接 500。  
44 -  
45 -**MCP 查库(测试库)**:  
46 -  
47 -- `fl_label_template` **无** `AppliedPartnerType` / `AppliedRegionType` 列  
48 -- **无** `fl_label_template_partner` / `fl_label_template_region` 表  
49 -- **有** `AppliedLocationType` 与 `fl_label_template_location`  
50 -  
51 -### 2.2 次因:列表 SQL 其它隐患(一并修复)  
52 -  
53 -| # | 问题 | 后果 |  
54 -|---|------|------|  
55 -| 1 | 默认排序曾用 `LastModificationTime ?? CreationTime` | SqlSugar 翻译失败 → 500 |  
56 -| 2 | `Sorting` 直接 `OrderBy(input.Sorting)` 拼 SQL | 非法字段 / 注入风险 |  
57 -| 3 | 空 `templateIds` 仍 `Contains` 查询 | 可能生成 `IN ()` |  
58 -| 4 | 无效 partner/group 筛选未安全降级 | 未捕获异常 |  
59 -  
60 ----  
61 -  
62 -## 三、修复说明  
63 -  
64 -### 1. 从 ORM 实体移除不存在列 + 强制列投影(核心)  
65 -  
66 -| 改动 | 说明 |  
67 -|------|------|  
68 -| `FlLabelTemplateDbEntity` | **删除** `AppliedPartnerType` / `AppliedRegionType` 属性 |  
69 -| **`LabelTemplateQueryHelper.ProjectListColumns`** | 列表/详情/重复校验等只读查询 **显式 Select** 真实列,SQL 不再出现 scope 列 |  
70 -| `LabelTemplateScopeSchemaHelper` | 探测列/表;已迁移时用 raw SQL 写入 scope 列 |  
71 -| `LabelTemplateAppService` | `GetListAsync` 在排序后调用 `ProjectListColumns` |  
72 -  
73 -> **重要**:若 Yi-SQL 日志仍出现 `AppliedPartnerType`,说明进程加载的是**旧 DLL**(`FoodLabeling.Application` 未重新编译或未重启)。请先 `dotnet build` 通过后再**完全停止并重启** `Yi.Abp.Web`。  
74 -  
75 -### 2. 安全排序  
76 -  
77 -新增 `ApplyLabelTemplateListSorting`:  
78 -  
79 -- 默认:`ORDER BY IFNULL(LastModificationTime, CreationTime) DESC, TemplateCode ASC`  
80 -- `Sorting` 白名单字段 + asc/desc  
81 -  
82 -### 3. 列表查询健壮性  
83 -  
84 -| 改动 | 说明 |  
85 -|------|------|  
86 -| `input ??= new()`、`pageSize` 默认 10 | 入参/分页兜底 |  
87 -| `templateIds.Count > 0` 再查 elements/items | 避免空 `IN ()` |  
88 -| `ResolveFilteredLocationIdsForListAsync` | 无效 partner/group 返回空列表 |  
89 -| `DeleteAsync` | 仅当 scope 关联表存在时才删除 partner/region 行 |  
90 -  
91 -### 4. 与 6-17 的关系  
92 -  
93 -6-17 用 `IsIgnore` 试图跳过列映射,**实测无效**;6-18 改为**实体不含列 + raw SQL 按需写入**,与 `fl_label.AppliedRegionType` 处理方式一致。  
94 -  
95 ----  
96 -  
97 -## 四、接口说明  
98 -  
99 -| 项目 | 内容 |  
100 -|------|------|  
101 -| 方法 | `GET` |  
102 -| 路径 | `/api/app/label-template` |  
103 -| 鉴权 | Bearer Token(`Authorization: {data.token}`,`data.token` 已含 `Bearer ` 前缀) |  
104 -  
105 -### 入参(Query)  
106 -  
107 -| 参数 | 类型 | 必填 | 说明 |  
108 -|------|------|------|------|  
109 -| `SkipCount` | int | 否 | **页码,从 1 起**;第一页传 `1` |  
110 -| `MaxResultCount` | int | 否 | 每页条数;`<=0` 时后端按 **10** 处理 |  
111 -| `Keyword` | string | 否 | 模板名称 / 编码模糊搜索 |  
112 -| `PartnerId` | string | 否 | 按 Company(`fl_partner.Id`)筛选 |  
113 -| `GroupId` | string | 否 | 按 Region(`fl_group.Id`)筛选 |  
114 -| `LocationId` | string | 否 | 按门店(`location.Id`)筛选;**优先于** Partner/Region |  
115 -| `LabelType` | string | 否 | 如 `PRICE` / `NUTRITION` |  
116 -| `State` | bool | 否 | 启用状态 |  
117 -| `Sorting` | string | 否 | 白名单:`TemplateName asc/desc`、`TemplateCode asc/desc`、`CreationTime asc/desc`、`LastModificationTime asc/desc`;其它值忽略并走默认排序 |  
118 -  
119 -筛选解析顺序:**LocationId → GroupId → PartnerId**;均未传则不按门店范围收窄(仍返回全部未删除模板,除非前端传了无效 Id 则返回空列表)。  
120 -  
121 -### 出参(`PagedResultWithPageDto<LabelTemplateGetListOutputDto>`)  
122 -  
123 -| 字段 | 说明 |  
124 -|------|------|  
125 -| `pageIndex` | 当前页码 |  
126 -| `pageSize` | 每页条数 |  
127 -| `totalCount` | 总条数 |  
128 -| `totalPages` | 总页数 |  
129 -| `items[]` | 模板列表 |  
130 -  
131 -**`items[]` 主要字段**  
132 -  
133 -| 字段 | 说明 |  
134 -|------|------|  
135 -| `id` / `templateCode` | 模板编码(前端主键) |  
136 -| `templateName` | 模板名称 |  
137 -| `company` / `region` / `location` | 适用范围展示;未迁移 scope 库时 Company/Region 为 `All Companies` / `All Regions` |  
138 -| `partnerIds` / `regionIds` / `locationIds` | 对应 Id 数组 |  
139 -| `items` / `itemNames` | 模板内控件名称 |  
140 -| `contentsCount` | 控件数量 |  
141 -| `sizeText` | 如 `2x2inch` |  
142 -| `lastEdited` | 最近编辑时间(`LastModificationTime ?? CreationTime`) |  
143 -  
144 ----  
145 -  
146 -## 五、请求示例  
147 -  
148 -### 登录获取 Token  
149 -  
150 -```bash  
151 -curl -X POST "http://flus-test.3ffoodsafety.com/api/oauth/Login" \  
152 - -H "Content-Type: application/x-www-form-urlencoded" \  
153 - -d "userName=admin&password=123456"  
154 -```  
155 -  
156 -### 列表(第一页,默认排序)  
157 -  
158 -```bash  
159 -curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \  
160 - -H "Authorization: Bearer {token}" \  
161 - --data-urlencode "SkipCount=1" \  
162 - --data-urlencode "MaxResultCount=10"  
163 -```  
164 -  
165 -### 带 Company 筛选  
166 -  
167 -```bash  
168 -curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \  
169 - -H "Authorization: Bearer {token}" \  
170 - --data-urlencode "SkipCount=1" \  
171 - --data-urlencode "MaxResultCount=10" \  
172 - --data-urlencode "PartnerId={fl_partner.Id}"  
173 -```  
174 -  
175 -### 指定排序  
176 -  
177 -```bash  
178 -curl -G "http://flus-test.3ffoodsafety.com/api/app/label-template" \  
179 - -H "Authorization: Bearer {token}" \  
180 - --data-urlencode "SkipCount=1" \  
181 - --data-urlencode "MaxResultCount=10" \  
182 - --data-urlencode "Sorting=TemplateName asc"  
183 -```  
184 -  
185 -### 响应片段(示例)  
186 -  
187 -```json  
188 -{  
189 - "pageIndex": 1,  
190 - "pageSize": 10,  
191 - "totalCount": 3,  
192 - "totalPages": 1,  
193 - "items": [  
194 - {  
195 - "id": "tpl_n0b5h9_mpyssitm",  
196 - "templateCode": "tpl_n0b5h9_mpyssitm",  
197 - "templateName": "Retail Label w/Price Copy",  
198 - "company": "All Companies",  
199 - "region": "All Regions",  
200 - "location": "Ordos Airport, Store B",  
201 - "items": "Label Name, Price, Barcode",  
202 - "contentsCount": 5,  
203 - "sizeText": "2x2inch",  
204 - "lastEdited": "2026-06-04T08:30:00"  
205 - }  
206 - ]  
207 -}  
208 -```  
209 -  
210 ----  
211 -  
212 -## 六、验证步骤  
213 -  
214 -0. **重新编译并重启**(必做):  
215 - ```bash  
216 - cd "美国版/Food Labeling Management Code/Yi.Abp.Net8/src/Yi.Abp.Web"  
217 - dotnet build  
218 - ```  
219 - 停止正在运行的 Web 进程后重新启动;确认 Yi-SQL 中 **不再出现** `AppliedPartnerType`。  
220 -  
221 -1. **部署**包含 6-17 + 6-18 的后端并重启。  
222 -2. 调用 `GET /api/app/label-template?SkipCount=1&MaxResultCount=10` → 应 **200**,`totalCount >= 0`。  
223 -3. Web **Label Templates** 列表可加载,不再出现红色 **Failed to load label templates**。  
224 -4. 传无效 `PartnerId` / `GroupId` → **200** 且 `items=[]`(非 500)。  
225 -5. 不传 `Sorting` → 按最近编辑时间降序;传 `Sorting=TemplateName asc` → 按名称升序。  
226 -6. 分页:第 2 页 `SkipCount=2`,`totalCount` 与 UI 一致。  
227 -  
228 -### SQL 抽查(修复后 ORM 应生成的列)  
229 -  
230 -```sql  
231 -SELECT Id, TemplateCode, TemplateName, AppliedLocationType,  
232 - IFNULL(LastModificationTime, CreationTime) AS LastEdited  
233 -FROM fl_label_template  
234 -WHERE IsDeleted = 0  
235 -ORDER BY IFNULL(LastModificationTime, CreationTime) DESC, TemplateCode ASC  
236 -LIMIT 10;  
237 -```  
238 -  
239 -**不应再出现** `AppliedPartnerType` / `AppliedRegionType`。  
240 -  
241 -### 检查 scope 是否已迁移  
242 -  
243 -```sql  
244 -SELECT COUNT(*) AS scope_table_cnt  
245 -FROM information_schema.TABLES  
246 -WHERE TABLE_SCHEMA = DATABASE()  
247 - AND TABLE_NAME IN ('fl_label_template_partner', 'fl_label_template_region');  
248 -```  
249 -  
250 -`scope_table_cnt = 0` 时行为与 6-17 一致:仅 Location 维度落库与展示。  
251 -  
252 ----  
253 -  
254 -## 七、涉及代码  
255 -  
256 -| 文件 | 说明 |  
257 -|------|------|  
258 -| `Helpers/LabelTemplateQueryHelper.cs` | **新增** `ProjectListColumns` 强制 SQL 列白名单 |  
259 -| `Services/DbModels/FlLabelTemplateDbEntity.cs` | **移除** Partner/Region 列属性 |  
260 -| `Helpers/LabelTemplateScopeSchemaHelper.cs` | 列/表探测 + `SetAppliedScopeTypesAsync` raw SQL |  
261 -| `Services/LabelTemplateAppService.cs` | 安全排序、Create/Update 写 scope 列、列表健壮性 |  
262 -| `Helpers/LabelTemplateScopeHelper.cs` | 未迁移库 scope 过滤/展示(6-17) |  
263 -| `Helpers/LocationScopeBindingHelper.cs` | `ResolveFilteredLocationIdsForListAsync` |  
264 -| `Helpers/LabelTemplateListItemsHelper.cs` | Items 列 |  
265 -| `Dtos/LabelTemplate/LabelTemplateGetListInputVo.cs` | `PartnerId` 筛选 |  
266 -  
267 ----  
268 -  
269 -## 八、数据库迁移(可选)  
270 -  
271 -需完整 Company / Region 三维 scope 时,执行:  
272 -  
273 -`美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/scripts/fl_label_template_scope.sql`  
274 -  
275 -未执行前:**列表可正常返回**(6-17 + 6-18),但无法持久化 Company/Region 多选明细。  
276 -  
277 ----  
278 -  
279 -## 九、App 标签预览 `POST /api/app/us-app-labeling/preview`  
280 -  
281 -### 现象  
282 -  
283 -App 标签预览页调用 preview 失败(500 或 400),常与 **label-template 列表** 同源:加载模板头时 ORM 仍 SELECT 不存在的 `AppliedPartnerType` / `AppliedRegionType`。  
284 -  
285 -另有两项逻辑缺陷(与 `6-11` 文档不一致):  
286 -  
287 -| # | 问题 | 后果 |  
288 -|---|------|------|  
289 -| 1 | `LabelAppService.PreviewAsync` 全表查询 `FlLabelTemplateDbEntity` | 未迁移 scope 列时 **Unknown column** → 500 |  
290 -| 2 | `UsAppLabelingAppService.PreviewAsync` 未向 `_labelAppService.PreviewAsync` 传 **`locationId`** | 模板含 Company 自动生成元素时报 **「预览/打印需要 locationId 以填充 Company 信息」** |  
291 -| 3 | 出参 **`labelId`** 仍返回 `fl_label.Id`(GUID) | 与 Print Log 当日序号 `yyyyMMdd-n` 不一致 |  
292 -  
293 -### 修复说明  
294 -  
295 -| 改动 | 说明 |  
296 -|------|------|  
297 -| `LabelAppService.PreviewAsync` | 模板头查询改用 `LabelTemplateQueryHelper.ProjectListColumns` |  
298 -| `UsAppLabelingAppService.PreviewAsync` | 传入 `LocationId`;`labelId` 改用 `ReportsPrintLogDailyLabelIdHelper.ResolveNextDailyLabelIdAsync` |  
299 -| `UsAppLabelingAppService.PrintAsync` | 解析模板时同步传入 `LocationId`(打印与预览 Company 填充一致) |  
300 -| `DashboardAppService` | 模板统计 Count 同样走 `ProjectListColumns` |  
301 -  
302 -### 接口说明  
303 -  
304 -| 项目 | 内容 |  
305 -|------|------|  
306 -| 方法 | `POST` |  
307 -| 路径 | `/api/app/us-app-labeling/preview` |  
308 -| 鉴权 | App Bearer Token |  
309 -  
310 -#### 入参(Body:`UsAppLabelPreviewInputVo`)  
311 -  
312 -| 字段 | 类型 | 必填 | 说明 |  
313 -|------|------|------|------|  
314 -| `locationId` | string | 是 | 当前门店 Id(`location.Id`) |  
315 -| `labelCode` | string | 是 | 标签编码(`fl_label.LabelCode`) |  
316 -| `productId` | string | 否 | 预览产品 Id;不传则取标签绑定第一个产品 |  
317 -| `baseTime` | DateTime | 否 | 日期/时间控件基准;也用于计算当日 `labelId` 序号;未传为服务器当前时间 |  
318 -| `printInputJson` | object | 否 | `PRINT_INPUT` 元素用户输入 |  
319 -  
320 -#### 出参(`UsAppLabelPreviewDto`)  
321 -  
322 -| 字段 | 说明 |  
323 -|------|------|  
324 -| **`labelId`** | 门店当日**下一个**打印序号 `yyyyMMdd-n`(预览不落库) |  
325 -| `locationId` / `labelCode` | 回传入参 |  
326 -| `template` | 已解析 AUTO_DB / PRINT_INPUT 的模板结构(含 Company 自动填充) |  
327 -| `labelLastEdited` | 标签最近编辑时间 |  
328 -| 其它 | `typeName`、`productName`、`templateProductDefaultValues` 等 |  
329 -  
330 -#### `labelId` 规则  
331 -  
332 -与 `6-11`、`get-print-log-list` 一致:`{baseTime 日期 yyyyMMdd}-{当日已有打印任务数 + 1}`。  
333 -  
334 -#### Company 自动元素  
335 -  
336 -模板含 Company 自动生成控件时,后端根据 **`locationId`** 查 `fl_partner` 填充 `config.text`(详见 `6-11` 第二节)。  
337 -  
338 -### 请求示例  
339 -  
340 -```bash  
341 -curl -X POST "http://flus-test.3ffoodsafety.com/api/app/us-app-labeling/preview" \  
342 - -H "Authorization: Bearer {token}" \  
343 - -H "Content-Type: application/json" \  
344 - -d '{  
345 - "locationId": "550e8400-e29b-41d4-a716-446655440000",  
346 - "labelCode": "LBL0001",  
347 - "productId": "PROD001",  
348 - "baseTime": "2026-06-18T09:00:00"  
349 - }'  
350 -```  
351 -  
352 -### 响应片段(示例)  
353 -  
354 -```json  
355 -{  
356 - "labelId": "20260618-3",  
357 - "locationId": "550e8400-e29b-41d4-a716-446655440000",  
358 - "labelCode": "LBL0001",  
359 - "labelLastEdited": "2026-06-01T08:30:09",  
360 - "template": {  
361 - "id": "tpl_xxx",  
362 - "width": 2,  
363 - "height": 2,  
364 - "unit": "inch",  
365 - "elements": []  
366 - }  
367 -}  
368 -```  
369 -  
370 -### 验证步骤  
371 -  
372 -1. 停服 → `dotnet build` → 重启(同 label-template 一节)。  
373 -2. App 进入标签预览页,Network 中 preview 应 **200**。  
374 -3. 响应 `labelId` 为 `yyyyMMdd-n`,非 GUID。  
375 -4. 模板含 Company 元素时,`template.elements` 中对应 `config.text` 为门店所属公司名。  
376 -5. 当日已有 N 条打印任务时,preview 返回 `-{N+1}`。  
377 -  
378 -### 涉及代码(preview)  
379 -  
380 -| 文件 | 说明 |  
381 -|------|------|  
382 -| `Services/UsAppLabelingAppService.cs` | `PreviewAsync` / `PrintAsync` 传 `LocationId`、当日 `labelId` |  
383 -| `Services/LabelAppService.cs` | `PreviewAsync` 模板头 `ProjectListColumns` |  
384 -| `Helpers/LabelTemplateQueryHelper.cs` | 列投影 |  
385 -| `Helpers/ReportsPrintLogDailyLabelIdHelper.cs` | `ResolveNextDailyLabelIdAsync` |  
386 -| `Helpers/PartnerCompanyDisplayHelper.cs` | Company 自动填充 |  
387 -  
388 ----  
389 -  
390 -## 十、App 打印日志 `POST /api/app/us-app-labeling/get-print-log-list`  
391 -  
392 -### 现象  
393 -  
394 -Postman 传 `printDate: "2026-06-16"` 返回 `totalCount: 0`,误以为接口异常。  
395 -  
396 -### 根因(查库核对)  
397 -  
398 -门店 `3a218397-8dda-a378-e024-ef89bcef8d24` 在测试库中:  
399 -  
400 -| 自然日(`DATE(IFNULL(PrintedAt, CreationTime))`) | 记录数 |  
401 -|---------------------------------------------------|--------|  
402 -| `2026-06-01` | **7** |  
403 -| `2026-05-31` | **3** |  
404 -| `2026-06-16` | **0** |  
405 -  
406 -**接口按入参日期筛选时,该日无数据则返回空列表,行为正确。** 文档/示例误用 `2026-06-16`,应改用 **`2026-06-01`** 验证。  
407 -  
408 -另:`PrintedAt` 入库多为 `null`,筛选实际走 **`CreationTime`**。  
409 -  
410 -### 本轮修复  
411 -  
412 -| 改动 | 说明 |  
413 -|------|------|  
414 -| 日期条件 | 改用 MySQL `DATE(IFNULL(t.PrintedAt, t.CreationTime)) = 'yyyy-MM-dd'`,避免 DateTime 区间比较时区偏差 |  
415 -| `printDateDay` | 新增字符串入参(`yyyy-MM-dd`),优先于 `printDate`,避免 JSON 仅日期 UTC 歧义 |  
416 -| 未传日期 | **`printDate` 与 `printDateDay` 均未传时不按日过滤**(返回该门店全部打印记录,兼容 App 未传参) |  
417 -| `labelId` | 列表出参改为当日序号 `yyyyMMdd-n`;`labelEntityId` 为 `fl_label.Id` |  
418 -  
419 -### 请求示例(有数据的日期)  
420 -  
421 -```bash  
422 -curl -X POST "http://192.168.1.4:19001/api/app/us-app-labeling/get-print-log-list" \  
423 - -H "Authorization: Bearer {token}" \  
424 - -H "Content-Type: application/json" \  
425 - -d '{  
426 - "locationId": "3a218397-8dda-a378-e024-ef89bcef8d24",  
427 - "skipCount": 1,  
428 - "maxResultCount": 20,  
429 - "printDateDay": "2026-06-01"  
430 - }'  
431 -```  
432 -  
433 -或使用 `"printDate": "2026-06-01"`(等价)。  
434 -  
435 -### 权限说明  
436 -  
437 -非 admin / 非 Partner 角色时,仅返回 **`CreatedBy = 当前用户`** 的记录;若 Token 用户不是打印人,即使日期正确也会空列表。  
438 -  
439 -### 部署  
440 -  
441 -修改在 `FoodLabeling.Application`,需 **停服 → dotnet build → 重启** 后 Postman 才生效。  
442 -  
443 -### 涉及代码  
444 -  
445 -| 文件 | 说明 |  
446 -|------|------|  
447 -| `Helpers/ReportsPrintLogDailyLabelIdHelper.cs` | `ResolvePrintLogFilterCalendarDay`、`ApplyPrintTaskCalendarDayFilter` |  
448 -| `Dtos/UsAppLabeling/PrintLogGetListInputVo.cs` | `PrintDateDay` |  
449 -| `Services/UsAppLabelingAppService.cs` | `GetPrintLogListAsync` |  
450 -  
451 ----  
452 -  
453 -## 关联文档  
454 -  
455 -- App Preview labelId / Company:`项目相关文档/6-11代码优化.md`  
456 -- 第一轮 scope 兼容:`项目相关文档/6-17代码优化.md`(第一节、第二节 get-print-log-list)  
457 -- 三维 scope 业务规则:`项目相关文档/6-4代码优化.md`  
458 -- 分页约定:`Helpers/PagedQueryConvention.cs`  
美国版/Food Labeling Management App UniApp/src/App.vue
@@ -3,11 +3,13 @@ import { onLaunch, onShow, onHide } from &quot;@dcloudio/uni-app&quot;; @@ -3,11 +3,13 @@ import { onLaunch, onShow, onHide } from &quot;@dcloudio/uni-app&quot;;
3 import { initOfflineSqlite } from "./utils/sqliteSync"; 3 import { initOfflineSqlite } from "./utils/sqliteSync";
4 import { syncNowAndRefreshCaches } from "./utils/offlineSyncManager"; 4 import { syncNowAndRefreshCaches } from "./utils/offlineSyncManager";
5 import { isLoggedIn } from "./utils/authSession"; 5 import { isLoggedIn } from "./utils/authSession";
  6 +import { preloadAllLabelEditorFonts } from "./utils/labelEditorFonts";
6 7
7 let networkSyncInFlight = false; 8 let networkSyncInFlight = false;
8 9
9 onLaunch(() => { 10 onLaunch(() => {
10 void initOfflineSqlite(); 11 void initOfflineSqlite();
  12 + void preloadAllLabelEditorFonts();
11 uni.onNetworkStatusChange((res) => { 13 uni.onNetworkStatusChange((res) => {
12 if (!res.isConnected || !isLoggedIn() || networkSyncInFlight) return; 14 if (!res.isConnected || !isLoggedIn() || networkSyncInFlight) return;
13 networkSyncInFlight = true; 15 networkSyncInFlight = true;
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
@@ -63,7 +63,41 @@ @@ -63,7 +63,41 @@
63 </text> 63 </text>
64 <view v-for="el in printFreeFieldList" :key="'free-' + el.id" class="print-option-block"> 64 <view v-for="el in printFreeFieldList" :key="'free-' + el.id" class="print-option-block">
65 <text class="print-option-label">{{ freeFieldNameLabel(el) }}</text> 65 <text class="print-option-label">{{ freeFieldNameLabel(el) }}</text>
66 - <template v-if="freeFieldInputKind(el) === 'text'"> 66 + <template v-if="isWeightPrintField(el) && readWeightInputMode(el.config || {}) === 'tare'">
  67 + <input
  68 + class="free-field-input"
  69 + type="digit"
  70 + :value="printFreeFieldValues[el.id] ?? ''"
  71 + :placeholder="weightInputPlaceholder('tare')"
  72 + @input="onFreeFieldInput(el.id, $event)"
  73 + />
  74 + <view class="weight-scale-actions">
  75 + <view
  76 + class="weight-scale-btn"
  77 + :class="{ disabled: weightScaleReadingId === el.id }"
  78 + @click="readWeightFromScale(el, 'tared')"
  79 + >
  80 + <text>{{ weightScaleReadingId === el.id && weightScaleReadingKind === 'tared' ? 'Reading…' : 'Read from scale (net / tared)' }}</text>
  81 + </view>
  82 + <view
  83 + class="weight-scale-btn"
  84 + :class="{ disabled: weightScaleReadingId === el.id }"
  85 + @click="readWeightFromScale(el, 'gross')"
  86 + >
  87 + <text>{{ weightScaleReadingId === el.id && weightScaleReadingKind === 'gross' ? 'Reading…' : 'Read from scale (gross)' }}</text>
  88 + </view>
  89 + </view>
  90 + </template>
  91 + <template v-else-if="isWeightPrintField(el)">
  92 + <input
  93 + class="free-field-input"
  94 + type="digit"
  95 + :value="printFreeFieldValues[el.id] ?? ''"
  96 + :placeholder="weightInputPlaceholder('net')"
  97 + @input="onFreeFieldInput(el.id, $event)"
  98 + />
  99 + </template>
  100 + <template v-else-if="freeFieldInputKind(el) === 'text'">
67 <input 101 <input
68 class="free-field-input" 102 class="free-field-input"
69 type="text" 103 type="text"
@@ -268,6 +302,12 @@ import { @@ -268,6 +302,12 @@ import {
268 validatePrintInputOptionsBeforePrint, 302 validatePrintInputOptionsBeforePrint,
269 } from '../../utils/labelPreview/printInputOptions' 303 } from '../../utils/labelPreview/printInputOptions'
270 import { 304 import {
  305 + readWeightInputMode,
  306 + weightInputPlaceholder,
  307 + type SmartScaleReadKind,
  308 +} from '../../utils/weightElement'
  309 +import { readWeightFromSmartScale } from '../../utils/smartScaleService'
  310 +import {
271 buildLabelPrintJobPayload, 311 buildLabelPrintJobPayload,
272 setLastLabelPrintJobPayload, 312 setLastLabelPrintJobPayload,
273 } from '../../utils/labelPreview/buildLabelPrintPayload' 313 } from '../../utils/labelPreview/buildLabelPrintPayload'
@@ -314,6 +354,7 @@ import { @@ -314,6 +354,7 @@ import {
314 import { 354 import {
315 normalizeTemplateForNativeFastJob, 355 normalizeTemplateForNativeFastJob,
316 templateHasUnsupportedNativeFastElements, 356 templateHasUnsupportedNativeFastElements,
  357 + templateRequiresCanvasStyleFidelity,
317 } from '../../utils/print/nativeTemplateElementSupport' 358 } from '../../utils/print/nativeTemplateElementSupport'
318 import { 359 import {
319 ensureTemplateHeightCoversElements, 360 ensureTemplateHeightCoversElements,
@@ -834,6 +875,8 @@ const printOptionSelections = ref&lt;Record&lt;string, string[]&gt;&gt;({}) @@ -834,6 +875,8 @@ const printOptionSelections = ref&lt;Record&lt;string, string[]&gt;&gt;({})
834 const dictLabelsByElementId = ref<Record<string, string>>({}) 875 const dictLabelsByElementId = ref<Record<string, string>>({})
835 const dictValuesByElementId = ref<Record<string, string[]>>({}) 876 const dictValuesByElementId = ref<Record<string, string[]>>({})
836 const printFreeFieldValues = ref<Record<string, string>>({}) 877 const printFreeFieldValues = ref<Record<string, string>>({})
  878 +const weightScaleReadingId = ref('')
  879 +const weightScaleReadingKind = ref<SmartScaleReadKind | ''>('')
837 const pickerDialogVisible = ref(false) 880 const pickerDialogVisible = ref(false)
838 const pickerMode = ref<'date' | 'time' | 'datetime'>('date') 881 const pickerMode = ref<'date' | 'time' | 'datetime'>('date')
839 const pickerSelection = ref<number[]>([0, 0, 0, 0, 0]) 882 const pickerSelection = ref<number[]>([0, 0, 0, 0, 0])
@@ -913,15 +956,40 @@ function freeFieldNameLabel(el: SystemTemplateElementBase): string { @@ -913,15 +956,40 @@ function freeFieldNameLabel(el: SystemTemplateElementBase): string {
913 return `${n}:` 956 return `${n}:`
914 } 957 }
915 958
  959 +function isWeightPrintField(el: SystemTemplateElementBase): boolean {
  960 + return String(el.type || '').toUpperCase() === 'WEIGHT'
  961 +}
  962 +
916 function freeFieldPlaceholder(el: SystemTemplateElementBase): string { 963 function freeFieldPlaceholder(el: SystemTemplateElementBase): string {
917 const c = el.config || {} 964 const c = el.config || {}
918 const type = String(el.type || '').toUpperCase() 965 const type = String(el.type || '').toUpperCase()
  966 + if (type === 'WEIGHT') {
  967 + return weightInputPlaceholder(readWeightInputMode(c))
  968 + }
919 if (type === 'DATE' || type === 'TIME') { 969 if (type === 'DATE' || type === 'TIME') {
920 return String(c.format ?? c.Format ?? '') 970 return String(c.format ?? c.Format ?? '')
921 } 971 }
922 return '' 972 return ''
923 } 973 }
924 974
  975 +async function readWeightFromScale(el: SystemTemplateElementBase, kind: SmartScaleReadKind) {
  976 + if (weightScaleReadingId.value) return
  977 + weightScaleReadingId.value = el.id
  978 + weightScaleReadingKind.value = kind
  979 + try {
  980 + const value = await readWeightFromSmartScale(kind)
  981 + printFreeFieldValues.value = { ...printFreeFieldValues.value, [el.id]: value }
  982 + void refreshPreviewFromSelections()
  983 + uni.showToast({ title: 'Weight updated', icon: 'success' })
  984 + } catch (e) {
  985 + const msg = e instanceof Error ? e.message : String(e)
  986 + uni.showToast({ title: msg || 'Scale read failed', icon: 'none' })
  987 + } finally {
  988 + weightScaleReadingId.value = ''
  989 + weightScaleReadingKind.value = ''
  990 + }
  991 +}
  992 +
925 function freeFieldDateFormat(el: SystemTemplateElementBase): string { 993 function freeFieldDateFormat(el: SystemTemplateElementBase): string {
926 const c = el.config || {} 994 const c = el.config || {}
927 return String(c.format ?? c.Format ?? '').trim() 995 return String(c.format ?? c.Format ?? '').trim()
@@ -1644,6 +1712,7 @@ const handlePrint = async () =&gt; { @@ -1644,6 +1712,7 @@ const handlePrint = async () =&gt; {
1644 canPrintCurrentLabelViaNativeFastJob() 1712 canPrintCurrentLabelViaNativeFastJob()
1645 && isTemplateWithinNativeFastPrintBounds(tmpl) 1713 && isTemplateWithinNativeFastPrintBounds(tmpl)
1646 && !templateHasUnsupportedNativeFastElements(tmplForNativeJob) 1714 && !templateHasUnsupportedNativeFastElements(tmplForNativeJob)
  1715 + && !templateRequiresCanvasStyleFidelity(tmpl)
1647 1716
1648 if (ENABLE_PRINT_PREFLIGHT_MODAL) { 1717 if (ENABLE_PRINT_PREFLIGHT_MODAL) {
1649 // 一体机无 console:打印前弹设备链路信息(仅调试模式开启) 1718 // 一体机无 console:打印前弹设备链路信息(仅调试模式开启)
@@ -1863,7 +1932,8 @@ const handlePrint = async () =&gt; { @@ -1863,7 +1932,8 @@ const handlePrint = async () =&gt; {
1863 const shouldUseDirectTemplate = 1932 const shouldUseDirectTemplate =
1864 driver.protocol === 'tsc' && 1933 driver.protocol === 'tsc' &&
1865 templateHasQrDataForCommandPrint(tmpl) && 1934 templateHasQrDataForCommandPrint(tmpl) &&
1866 - !templateHasUnsupportedElementsForCommandPrint(tmpl) 1935 + !templateHasUnsupportedElementsForCommandPrint(tmpl) &&
  1936 + !templateRequiresCanvasStyleFidelity(tmpl)
1867 if (shouldUseDirectTemplate) { 1937 if (shouldUseDirectTemplate) {
1868 const directJobMs = 240000 1938 const directJobMs = 240000
1869 if (globalWatchdog) { 1939 if (globalWatchdog) {
@@ -2485,6 +2555,30 @@ const handlePrint = async () =&gt; { @@ -2485,6 +2555,30 @@ const handlePrint = async () =&gt; {
2485 border-radius: 12rpx; 2555 border-radius: 12rpx;
2486 } 2556 }
2487 2557
  2558 +.weight-scale-actions {
  2559 + display: flex;
  2560 + flex-direction: column;
  2561 + gap: 16rpx;
  2562 + margin-top: 16rpx;
  2563 +}
  2564 +
  2565 +.weight-scale-btn {
  2566 + padding: 18rpx 24rpx;
  2567 + border-radius: 12rpx;
  2568 + background: #eff6ff;
  2569 + border: 2rpx solid #bfdbfe;
  2570 +}
  2571 +
  2572 +.weight-scale-btn.disabled {
  2573 + opacity: 0.6;
  2574 +}
  2575 +
  2576 +.weight-scale-btn text {
  2577 + font-size: 24rpx;
  2578 + color: #1d4ed8;
  2579 + font-weight: 600;
  2580 +}
  2581 +
2488 .picker-input { 2582 .picker-input {
2489 display: flex; 2583 display: flex;
2490 align-items: center; 2584 align-items: center;
美国版/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/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/normalizePreviewTemplate.ts
@@ -51,8 +51,16 @@ function mergeFlatElementFieldsIntoConfig( @@ -51,8 +51,16 @@ function mergeFlatElementFieldsIntoConfig(
51 'FontSize', 51 'FontSize',
52 'textAlign', 52 'textAlign',
53 'TextAlign', 53 'TextAlign',
  54 + 'verticalAlign',
  55 + 'VerticalAlign',
54 'fontFamily', 56 'fontFamily',
  57 + 'FontFamily',
55 'fontWeight', 58 'fontWeight',
  59 + 'FontWeight',
  60 + 'fontStyle',
  61 + 'FontStyle',
  62 + 'textDecoration',
  63 + 'TextDecoration',
56 'color', 64 'color',
57 'Color', 65 'Color',
58 'src', 66 'src',
@@ -81,6 +89,8 @@ function mergeFlatElementFieldsIntoConfig( @@ -81,6 +89,8 @@ function mergeFlatElementFieldsIntoConfig(
81 'SelectedOptionValues', 89 'SelectedOptionValues',
82 'errorLevel', 90 'errorLevel',
83 'scaleMode', 91 'scaleMode',
  92 + 'weightInputMode',
  93 + 'WeightInputMode',
84 'showText', 94 'showText',
85 'placeholder', 95 'placeholder',
86 'Placeholder', 96 'Placeholder',
@@ -550,7 +560,7 @@ export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLa @@ -550,7 +560,7 @@ export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLa
550 width: Number(e.width ?? e.Width ?? 0), 560 width: Number(e.width ?? e.Width ?? 0),
551 height: Number(e.height ?? e.Height ?? 0), 561 height: Number(e.height ?? e.Height ?? 0),
552 rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical', 562 rotation: String(e.rotation ?? e.Rotation ?? 'horizontal') as 'horizontal' | 'vertical',
553 - border: String(e.border ?? e.BorderType ?? e.borderType ?? 'none'), 563 + border: String(e.border ?? e.Border ?? e.BorderType ?? e.borderType ?? 'none'),
554 config: cfg as Record<string, any>, 564 config: cfg as Record<string, any>,
555 zIndex: Number(e.zIndex ?? e.ZIndex ?? 0), 565 zIndex: Number(e.zIndex ?? e.ZIndex ?? 0),
556 orderNum: Number(e.orderNum ?? e.OrderNum ?? index), 566 orderNum: Number(e.orderNum ?? e.OrderNum ?? index),
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/nutritionDefaultsMerge.ts
1 /** 1 /**
2 * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。 2 * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。
  3 + * `<` 前缀由模板 config 决定,录入 JSON 不包含 LessThan 字段。
3 */ 4 */
  5 +import { NUTRITION_FACTS_LAYOUT_ROWS } from '../nutritionFactsLayout'
  6 +
  7 +function fixedLabelForKey(key: string): string {
  8 + const hit = NUTRITION_FACTS_LAYOUT_ROWS.find((x) => x.key === key)
  9 + return hit?.label ?? key
  10 +}
  11 +
  12 +function pickManual(manual: Record<string, string>, subKey: string): string {
  13 + return String(manual[subKey] ?? '').trim()
  14 +}
  15 +
4 export function applyNutritionDefaultJsonToConfig( 16 export function applyNutritionDefaultJsonToConfig(
5 baseCfg: Record<string, unknown>, 17 baseCfg: Record<string, unknown>,
6 jsonStr: string, 18 jsonStr: string,
7 ): Record<string, unknown> { 19 ): Record<string, unknown> {
8 - const t = String(jsonStr ?? "").trim();  
9 - if (!t.startsWith("{")) return baseCfg;  
10 - let manual: Record<string, string> = {}; 20 + const t = String(jsonStr ?? '').trim()
  21 + if (!t.startsWith('{')) return baseCfg
  22 + let manual: Record<string, string> = {}
11 try { 23 try {
12 - manual = JSON.parse(t) as Record<string, string>; 24 + manual = JSON.parse(t) as Record<string, string>
13 } catch { 25 } catch {
14 - return baseCfg; 26 + return baseCfg
  27 + }
  28 +
  29 + const out: Record<string, unknown> = { ...baseCfg }
  30 + const baseFixed = Array.isArray(baseCfg.fixedNutrients)
  31 + ? (baseCfg.fixedNutrients as Record<string, unknown>[])
  32 + : []
  33 +
  34 + const cal = pickManual(manual, 'calories')
  35 + if (cal) out.calories = cal
  36 + else {
  37 + delete out.calories
  38 + delete out.Calories
  39 + }
  40 + out.servingsPerContainer = pickManual(manual, 'servingsPerContainer')
  41 + out.servingSize = pickManual(manual, 'servingSize')
  42 +
  43 + const keysInOrder = [...NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key)]
  44 + for (const row of baseFixed) {
  45 + const key = String(row.key ?? '').trim()
  46 + if (key && !keysInOrder.includes(key)) keysInOrder.push(key)
  47 + }
  48 +
  49 + const fixedArr: Record<string, unknown>[] = []
  50 + for (const key of keysInOrder) {
  51 + const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === key)
  52 + const layout = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key)
  53 + const v = pickManual(manual, key)
  54 + const pct = pickManual(manual, `${key}Percent`)
  55 + const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`])
  56 + const label = String(baseRow?.label ?? layout?.label ?? fixedLabelForKey(key))
  57 + fixedArr.push({
  58 + key,
  59 + label,
  60 + value: v,
  61 + unit: '',
  62 + dailyValuePercent: pct,
  63 + lessThan,
  64 + })
  65 + if (v) out[key] = v
  66 + else delete out[key]
  67 + delete out[`${key}Unit`]
  68 + out[`${key}Percent`] = pct
  69 + out[`${key}LessThan`] = lessThan
15 } 70 }
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; 71 + out.fixedNutrients = fixedArr
  72 +
  73 + const newExtras: Array<{ id: string; name: string; value: string; unit: string }> = []
  74 + for (const k of Object.keys(manual)) {
  75 + if (!k.startsWith('extra:') || !k.endsWith(':value')) continue
  76 + const id = k.slice('extra:'.length, -':value'.length)
  77 + newExtras.push({
  78 + id,
  79 + name: String(
  80 + (Array.isArray(out.extraNutrients)
  81 + ? (out.extraNutrients as Record<string, unknown>[]).find((row) => String(row.id ?? '') === id)
  82 + : undefined)?.name ?? 'Other',
  83 + ),
  84 + value: pickManual(manual, k),
  85 + unit: '',
  86 + })
  87 + out[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`)
  88 + out[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`])
53 } 89 }
54 - return out; 90 + if (newExtras.length > 0) out.extraNutrients = newExtras
  91 +
  92 + delete out.ingredientsText
  93 + delete out.IngredientsText
  94 + return out
55 } 95 }
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/printInputOptions.ts
@@ -4,6 +4,7 @@ import type { @@ -4,6 +4,7 @@ import type {
4 SystemTemplateElementBase, 4 SystemTemplateElementBase,
5 } from '../print/types/printer' 5 } from '../print/types/printer'
6 import { isUniAppDateTimeOffsetField } from './printInputOffset' 6 import { isUniAppDateTimeOffsetField } from './printInputOffset'
  7 +import { formatWeightDisplay } from '../weightElement'
7 8
8 /** 9 /**
9 * 打印/预览 printInputJson 的 key:与接口文档一致优先 inputKey,否则 elementName,最后兜底元素 id。 10 * 打印/预览 printInputJson 的 key:与接口文档一致优先 inputKey,否则 elementName,最后兜底元素 id。
@@ -123,7 +124,12 @@ export function mergePrintInputFreeFields( @@ -123,7 +124,12 @@ export function mergePrintInputFreeFields(
123 return { ...el, config: { ...cfg, text: ph } } 124 return { ...el, config: { ...cfg, text: ph } }
124 } 125 }
125 126
126 - const display = unit && !raw.endsWith(unit) ? `${raw}${unit}` : raw 127 + const display =
  128 + type === 'WEIGHT'
  129 + ? formatWeightDisplay(raw, unit)
  130 + : unit && !raw.endsWith(unit)
  131 + ? `${raw}${unit}`
  132 + : raw
127 const next = { ...cfg, text: display } as Record<string, any> 133 const next = { ...cfg, text: display } as Record<string, any>
128 if (type === 'WEIGHT') { 134 if (type === 'WEIGHT') {
129 next.value = raw 135 next.value = raw
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
@@ -5,22 +5,22 @@ import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral } from &#39; @@ -5,22 +5,22 @@ import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral } from &#39;
5 import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement' 5 import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement'
6 import QRCode from 'qrcode' 6 import QRCode from 'qrcode'
7 import { readInvertColors } from '../invertColorsConfig' 7 import { readInvertColors } from '../invertColorsConfig'
  8 +import {
  9 + ensureLabelEditorFontsForTemplate,
  10 + isLabelEditorFontItalicLoaded,
  11 + resolveLabelEditorFontFamily,
  12 +} from '../labelEditorFonts'
  13 +import { computeVerticalTextBlockOffset, readVerticalAlign, readElementBorder, readFontStyle, readFontWeight, readTextDecoration, readElementRotation } from '../textElementLayout'
8 14
9 -const NUTRITION_FIXED_ITEMS = [  
10 - { key: 'fat', label: 'Total Fat' },  
11 - { key: 'saturatedFat', label: 'Saturated Fat' },  
12 - { key: 'transFat', label: 'Trans Fat' },  
13 - { key: 'cholesterol', label: 'Cholesterol' },  
14 - { key: 'sodium', label: 'Sodium' },  
15 - { key: 'carbs', label: 'Total Carbohydrates' },  
16 - { key: 'dietaryFiber', label: 'Dietary Fiber' },  
17 - { key: 'totalSugar', label: 'Total Sugar' },  
18 - { key: 'protein', label: 'Protein' },  
19 - { key: 'vitaminA', label: 'Vitamin A' },  
20 - { key: 'vitaminC', label: 'Vitamin C' },  
21 - { key: 'calcium', label: 'Calcium' },  
22 - { key: 'iron', label: 'Iron' },  
23 -] 15 +import { computeImageDrawRect, readImageScaleMode } from '../imageScaleMode'
  16 +import {
  17 + buildNutritionFactsViewModel,
  18 + DEFAULT_NUTRITION_FOOTER_NOTE,
  19 + NUTRITION_AMOUNT_COL_WIDTH,
  20 + NUTRITION_BODY_FONT_SIZE,
  21 + NUTRITION_PCT_COL_WIDTH,
  22 + type NutritionDivider,
  23 +} from '../nutritionFactsLayout'
24 24
25 /** 与 Web LabelCanvas.unitToPx 一致:cm 用 37.8px/inch,保证与后台模板坐标系一致 */ 25 /** 与 Web LabelCanvas.unitToPx 一致:cm 用 37.8px/inch,保证与后台模板坐标系一致 */
26 const PX_PER_CM = 37.8 26 const PX_PER_CM = 37.8
@@ -64,6 +64,74 @@ function readFillColor(config: Record&lt;string, any&gt;): string { @@ -64,6 +64,74 @@ function readFillColor(config: Record&lt;string, any&gt;): string {
64 return String(config.color ?? config.Color ?? '#111827') 64 return String(config.color ?? config.Color ?? '#111827')
65 } 65 }
66 66
  67 +function applyCanvasFontFromConfig(
  68 + ctx: UniApp.CanvasContext,
  69 + config: Record<string, any>,
  70 + fontSize: number,
  71 +): void {
  72 + const fontFamily = resolveLabelEditorFontFamily(config)
  73 + const anyCtx = ctx as any
  74 + const weight = readFontWeight(config) === 'bold' ? 'bold' : 'normal'
  75 + const style = readFontStyle(config) === 'italic' ? 'italic' : 'normal'
  76 + if (typeof anyCtx.setFontFamily === 'function') {
  77 + anyCtx.setFontFamily(fontFamily)
  78 + }
  79 + if (typeof anyCtx.setFontWeight === 'function') {
  80 + anyCtx.setFontWeight(weight)
  81 + }
  82 + if (typeof anyCtx.font !== 'undefined') {
  83 + anyCtx.font = `${style} ${weight} ${fontSize}px "${fontFamily}"`
  84 + }
  85 +}
  86 +
  87 +function approxTextWidth(text: string, fontSize: number): number {
  88 + return Math.max(4, String(text).length * fontSize * 0.55)
  89 +}
  90 +
  91 +function drawStyledTextLine(
  92 + ctx: UniApp.CanvasContext,
  93 + line: string,
  94 + tx: number,
  95 + y: number,
  96 + fontSize: number,
  97 + align: string,
  98 + config: Record<string, any>,
  99 + fillColor: string,
  100 +): void {
  101 + const family = resolveLabelEditorFontFamily(config)
  102 + const italic = readFontStyle(config) === 'italic'
  103 + const underline = readTextDecoration(config) === 'underline'
  104 + const useSkewFallback = italic && !isLabelEditorFontItalicLoaded(family)
  105 + const anyCtx = ctx as any
  106 + const lineWidth = approxTextWidth(line, fontSize)
  107 +
  108 + if (useSkewFallback && typeof anyCtx.save === 'function') {
  109 + anyCtx.save()
  110 + anyCtx.transform(1, 0, -0.25, 1, tx * 0.08, 0)
  111 + }
  112 + ctx.fillText(line, tx, y)
  113 + if (underline) {
  114 + let x1 = tx
  115 + let x2 = tx + lineWidth
  116 + if (align === 'center') {
  117 + x1 = tx - lineWidth / 2
  118 + x2 = tx + lineWidth / 2
  119 + } else if (align === 'right') {
  120 + x1 = tx - lineWidth
  121 + x2 = tx
  122 + }
  123 + ctx.setStrokeStyle(fillColor)
  124 + ctx.setLineWidth(Math.max(1, Math.round(fontSize * 0.06)))
  125 + ctx.beginPath()
  126 + ctx.moveTo(x1, y + Math.max(1, Math.round(fontSize * 0.12)))
  127 + ctx.lineTo(x2, y + Math.max(1, Math.round(fontSize * 0.12)))
  128 + ctx.stroke()
  129 + }
  130 + if (useSkewFallback && typeof anyCtx.restore === 'function') {
  131 + anyCtx.restore()
  132 + }
  133 +}
  134 +
67 /** 按元素框宽度估算每行最大字符数(等宽近似,兼容中英文) */ 135 /** 按元素框宽度估算每行最大字符数(等宽近似,兼容中英文) */
68 function maxCharsPerLine(innerWidthPx: number, fontSize: number): number { 136 function maxCharsPerLine(innerWidthPx: number, fontSize: number): number {
69 if (innerWidthPx <= 4) return 8 137 if (innerWidthPx <= 4) return 8
@@ -357,33 +425,181 @@ function drawQrCodePreview( @@ -357,33 +425,181 @@ function drawQrCodePreview(
357 } 425 }
358 } 426 }
359 427
360 -function nutritionFixedField(cfg: Record<string, any>, key: string, field: 'value' | 'unit'): string {  
361 - const directKey = field === 'value' ? key : `${key}Unit`  
362 - const direct = cfg[directKey]  
363 - if (direct != null && String(direct).trim() !== '') return String(direct).trim()  
364 - const rows = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Array<Record<string, unknown>>) : []  
365 - const row = rows.find((item) => String(item.key ?? '').trim() === key)  
366 - return String(row?.[field] ?? '').trim() 428 +
  429 +/** 双下划线两线间距(px),与 Web NutritionFactsPanel 一致 */
  430 +const NUTRITION_DOUBLE_LINE_GAP = 3
  431 +
  432 +function drawNutritionDivider(
  433 + ctx: UniApp.CanvasContext,
  434 + x1: number,
  435 + x2: number,
  436 + y: number,
  437 + kind: NutritionDivider,
  438 +): number {
  439 + if (kind === 'none') return 0
  440 + ctx.setStrokeStyle('#111827')
  441 + ctx.setLineWidth(1)
  442 + if (kind === 'double') {
  443 + ctx.beginPath()
  444 + ctx.moveTo(x1, y)
  445 + ctx.lineTo(x2, y)
  446 + ctx.stroke()
  447 + const y2 = y + 1 + NUTRITION_DOUBLE_LINE_GAP
  448 + ctx.beginPath()
  449 + ctx.moveTo(x1, y2)
  450 + ctx.lineTo(x2, y2)
  451 + ctx.stroke()
  452 + return 1 + NUTRITION_DOUBLE_LINE_GAP + 1 + 2
  453 + }
  454 + ctx.beginPath()
  455 + ctx.moveTo(x1, y)
  456 + ctx.lineTo(x2, y)
  457 + ctx.stroke()
  458 + return 2
367 } 459 }
368 460
369 -function nutritionExtraRows(cfg: Record<string, any>): Array<{ name: string; value: string; unit: string }> {  
370 - const raw = cfg.extraNutrients  
371 - if (!Array.isArray(raw)) return []  
372 - return raw.map((item) => {  
373 - const row = (item || {}) as Record<string, unknown>  
374 - return {  
375 - name: String(row.name ?? '').trim(),  
376 - value: String(row.value ?? '').trim(),  
377 - unit: String(row.unit ?? '').trim(), 461 +function drawNutritionFactsOnCanvas(
  462 + ctx: UniApp.CanvasContext,
  463 + config: Record<string, any>,
  464 + boxX: number,
  465 + boxY: number,
  466 + boxW: number,
  467 + boxH: number,
  468 +): void {
  469 + const model = buildNutritionFactsViewModel(config)
  470 + const pad = 6
  471 + const leftX = boxX + pad
  472 + const rightX = boxX + boxW - pad
  473 + const amountColRight = rightX - NUTRITION_PCT_COL_WIDTH
  474 + const amountX = amountColRight - NUTRITION_AMOUNT_COL_WIDTH / 2
  475 + const pctX = rightX
  476 + const maxY = boxY + boxH - pad
  477 + let cursorY = boxY + pad
  478 + const titleSize = Math.max(11, Math.min(18, model.titleFontSize))
  479 + const bodySize = NUTRITION_BODY_FONT_SIZE
  480 + const footerSize = Math.max(8, Math.round(bodySize * 0.67))
  481 + /** 正文用 Roboto,避免 FreightSans Bold 导致整表加粗(与 Web 一致) */
  482 + const bodyCfg = { ...config, fontFamily: 'Roboto', FontFamily: 'Roboto' }
  483 +
  484 + ctx.setFillStyle('#ffffff')
  485 + ctx.fillRect(boxX, boxY, boxW, boxH)
  486 +
  487 + const rowStep = (fs: number) => fs + 4
  488 +
  489 + const drawLine = (label: string, value: string, fs: number, labelBold = false) => {
  490 + const f = Math.max(8, Math.round(fs))
  491 + const lh = rowStep(f)
  492 + if (cursorY + lh > maxY) return false
  493 + ctx.setFillStyle('#111827')
  494 + ctx.setFontSize(f)
  495 + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: labelBold ? 'bold' : 'normal' }, f)
  496 + ctx.setTextAlign('left')
  497 + ctx.fillText(label, leftX, cursorY + f)
  498 + if (value) {
  499 + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, f)
  500 + ctx.setTextAlign('right')
  501 + ctx.fillText(value, rightX, cursorY + f)
378 } 502 }
379 - })  
380 -} 503 + cursorY += lh
  504 + return true
  505 + }
  506 +
  507 + const drawNutrientRow = (
  508 + label: string,
  509 + amount: string,
  510 + pct: string,
  511 + fs: number,
  512 + labelBold: boolean,
  513 + indent: boolean,
  514 + divider: NutritionDivider,
  515 + ) => {
  516 + const f = Math.max(8, Math.round(fs))
  517 + const lh = rowStep(f)
  518 + if (cursorY + lh > maxY) return false
  519 + const labelX = leftX + (indent ? 10 : 0)
  520 + ctx.setFillStyle('#111827')
  521 + ctx.setFontSize(f)
  522 + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: labelBold ? 'bold' : 'normal' }, f)
  523 + ctx.setTextAlign('left')
  524 + ctx.fillText(label, labelX, cursorY + f)
  525 + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, f)
  526 + if (amount) {
  527 + ctx.setTextAlign('center')
  528 + ctx.fillText(amount, amountX, cursorY + f)
  529 + }
  530 + if (pct) {
  531 + ctx.setTextAlign('right')
  532 + ctx.fillText(pct, pctX, cursorY + f)
  533 + }
  534 + cursorY += lh
  535 + if (divider !== 'none') {
  536 + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, divider)
  537 + }
  538 + return true
  539 + }
  540 +
  541 + const drawFooterNote = () => {
  542 + if (cursorY + footerSize + 6 > maxY) return
  543 + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'thin')
  544 + ctx.setFontSize(footerSize)
  545 + ctx.setTextAlign('left')
  546 + const parts = DEFAULT_NUTRITION_FOOTER_NOTE.split(/(\b2000\b)/)
  547 + let fx = leftX
  548 + for (const part of parts) {
  549 + if (!part) continue
  550 + applyCanvasFontFromConfig(
  551 + ctx,
  552 + { ...bodyCfg, fontWeight: part === '2000' ? 'bold' : 'normal' },
  553 + footerSize,
  554 + )
  555 + ctx.fillText(part, fx, cursorY + footerSize)
  556 + fx += approxTextWidth(part, footerSize)
  557 + }
  558 + cursorY += footerSize + 2
  559 + }
  560 +
  561 + ctx.setFillStyle('#111827')
  562 + ctx.setFontSize(Math.round(titleSize))
  563 + applyCanvasFontFromConfig(ctx, { ...config, fontWeight: 'bold' }, titleSize)
  564 + ctx.setTextAlign('left')
  565 + ctx.fillText('Nutrition Facts', leftX, cursorY + titleSize)
  566 + cursorY += titleSize + 1
  567 + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double')
  568 +
  569 + drawLine(model.servingsLabel, model.servingsValue, bodySize)
  570 + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'thin')
  571 + drawLine(model.servingSizeLabel, model.servingSizeValue, bodySize)
  572 + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double')
  573 +
  574 + if (cursorY + bodySize + 3 <= maxY) {
  575 + ctx.setFontSize(bodySize)
  576 + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'bold' }, bodySize)
  577 + ctx.setTextAlign('left')
  578 + ctx.fillText(model.caloriesLabel, leftX, cursorY + bodySize)
  579 + applyCanvasFontFromConfig(ctx, { ...bodyCfg, fontWeight: 'normal' }, bodySize)
  580 + ctx.setTextAlign('right')
  581 + ctx.fillText(model.caloriesAmountText || model.caloriesValue, rightX, cursorY + bodySize)
  582 + cursorY += bodySize + 1
  583 + cursorY += drawNutritionDivider(ctx, leftX, rightX, cursorY, 'double')
  584 + }
381 585
382 -function nutritionValueWithLessThan(value: string, unit: string): string {  
383 - const v = String(value || '').trim()  
384 - const u = String(unit || '').trim()  
385 - if (!v && !u) return ''  
386 - return `<${v}${u ? ` ${u}` : ''}` 586 + for (const row of model.rows) {
  587 + if (
  588 + !drawNutrientRow(
  589 + row.label,
  590 + row.amountText,
  591 + row.dailyValueText,
  592 + bodySize,
  593 + row.labelBold,
  594 + row.indent,
  595 + row.dividerAfter,
  596 + )
  597 + ) {
  598 + break
  599 + }
  600 + }
  601 +
  602 + drawFooterNote()
387 } 603 }
388 604
389 function strokeTemplatePaperBorder ( 605 function strokeTemplatePaperBorder (
@@ -394,16 +610,38 @@ function strokeTemplatePaperBorder ( @@ -394,16 +610,38 @@ function strokeTemplatePaperBorder (
394 ) { 610 ) {
395 const border = String(template.border || '').toLowerCase() 611 const border = String(template.border || '').toLowerCase()
396 if (border !== 'line' && border !== 'dotted') return 612 if (border !== 'line' && border !== 'dotted') return
397 - ctx.setStrokeStyle('#333333')  
398 - ctx.setLineWidth(1) 613 + ctx.setStrokeStyle('#374151')
  614 + ctx.setLineWidth(2)
399 const w = Math.max(0, cw - 1) 615 const w = Math.max(0, cw - 1)
400 const h = Math.max(0, ch - 1) 616 const h = Math.max(0, ch - 1)
401 if (border === 'dotted' && typeof (ctx as any).setLineDash === 'function') { 617 if (border === 'dotted' && typeof (ctx as any).setLineDash === 'function') {
  618 + ;(ctx as any).setLineDash([4, 3], 0)
  619 + ctx.strokeRect(1, 1, w - 1, h - 1)
  620 + ;(ctx as any).setLineDash([], 0)
  621 + } else {
  622 + ctx.strokeRect(1, 1, w - 1, h - 1)
  623 + }
  624 +}
  625 +
  626 +/** 元素级边框:须在背景/文字之后绘制,避免被 invert 黑底盖住 */
  627 +function strokeElementBorder(
  628 + ctx: UniApp.CanvasContext,
  629 + x: number,
  630 + y: number,
  631 + w: number,
  632 + h: number,
  633 + border: string | undefined,
  634 +) {
  635 + const line = String(border || '').toLowerCase()
  636 + if (line !== 'line' && line !== 'solid' && line !== 'dotted') return
  637 + ctx.setStrokeStyle(line === 'dotted' ? '#9ca3af' : '#111827')
  638 + ctx.setLineWidth(1)
  639 + if (line === 'dotted' && typeof (ctx as any).setLineDash === 'function') {
402 ;(ctx as any).setLineDash([3, 3], 0) 640 ;(ctx as any).setLineDash([3, 3], 0)
403 - ctx.strokeRect(0.5, 0.5, w, h) 641 + ctx.strokeRect(x, y, w, h)
404 ;(ctx as any).setLineDash([], 0) 642 ;(ctx as any).setLineDash([], 0)
405 } else { 643 } else {
406 - ctx.strokeRect(0.5, 0.5, w, h) 644 + ctx.strokeRect(x, y, w, h)
407 } 645 }
408 } 646 }
409 647
@@ -428,7 +666,9 @@ function runLabelPreviewCanvasDraw( @@ -428,7 +666,9 @@ function runLabelPreviewCanvasDraw(
428 const sorted = sortElementsForPreview(template.elements || []) 666 const sorted = sortElementsForPreview(template.elements || [])
429 const rotateContent = normalizeTemplatePrintOrientation(template.printOrientation) === 'horizontal' 667 const rotateContent = normalizeTemplatePrintOrientation(template.printOrientation) === 'horizontal'
430 668
431 - return new Promise((resolve) => { 669 + return ensureLabelEditorFontsForTemplate(template).then(
  670 + () =>
  671 + new Promise((resolve) => {
432 const ctx = uni.createCanvasContext(canvasId, componentInstance) 672 const ctx = uni.createCanvasContext(canvasId, componentInstance)
433 ctx.setFillStyle('#ffffff') 673 ctx.setFillStyle('#ffffff')
434 ctx.scale(scale, scale) 674 ctx.scale(scale, scale)
@@ -457,108 +697,51 @@ function runLabelPreviewCanvasDraw( @@ -457,108 +697,51 @@ function runLabelPreviewCanvasDraw(
457 const h = Math.max(0, Number(el.height) || 0) 697 const h = Math.max(0, Number(el.height) || 0)
458 698
459 const next = () => drawRest(index + 1) 699 const next = () => drawRest(index + 1)
  700 + const finishElement = () => {
  701 + strokeElementBorder(ctx, x, y, w, h, readElementBorder(el))
  702 + next()
  703 + }
460 704
461 if (type === 'IMAGE' || type === 'LOGO') { 705 if (type === 'IMAGE' || type === 'LOGO') {
462 const src = resolveMediaUrlForApp(cfgStr(config, ['src', 'url', 'Src', 'Url'])) 706 const src = resolveMediaUrlForApp(cfgStr(config, ['src', 'url', 'Src', 'Url']))
  707 + const boxW = w || 80
  708 + const boxH = h || 40
  709 + const scaleMode = readImageScaleMode(config)
463 if (src) { 710 if (src) {
464 uni.getImageInfo({ 711 uni.getImageInfo({
465 src, 712 src,
466 success: (info) => { 713 success: (info) => {
467 try { 714 try {
468 - ctx.drawImage(info.path, x, y, w || info.width, h || info.height) 715 + const rect = computeImageDrawRect(
  716 + boxW,
  717 + boxH,
  718 + Number(info.width) || 0,
  719 + Number(info.height) || 0,
  720 + scaleMode,
  721 + )
  722 + ctx.drawImage(info.path, x + rect.dx, y + rect.dy, rect.dw, rect.dh)
469 } catch (_) { 723 } catch (_) {
470 ctx.setStrokeStyle('#cccccc') 724 ctx.setStrokeStyle('#cccccc')
471 ctx.setLineWidth(1) 725 ctx.setLineWidth(1)
472 - ctx.strokeRect(x, y, w || 80, h || 40) 726 + ctx.strokeRect(x, y, boxW, boxH)
473 } 727 }
474 - next() 728 + finishElement()
475 }, 729 },
476 fail: () => { 730 fail: () => {
477 ctx.setStrokeStyle('#cccccc') 731 ctx.setStrokeStyle('#cccccc')
478 - ctx.strokeRect(x, y, w || 80, h || 40)  
479 - next() 732 + ctx.strokeRect(x, y, boxW, boxH)
  733 + finishElement()
480 }, 734 },
481 }) 735 })
482 return 736 return
483 } 737 }
484 - next() 738 + finishElement()
485 return 739 return
486 } 740 }
487 741
488 if (type === 'NUTRITION') { 742 if (type === 'NUTRITION') {
489 - const bw = Math.max(40, w || 120)  
490 - const bh = Math.max(36, h || 96)  
491 - const pad = 3  
492 - /** 数值列更贴右边框,与原生 NUTRITION_VALUE_RIGHT_MARGIN 一致 */  
493 - const rightX = x + bw - 1  
494 - const maxY = y + bh - 2  
495 - const titleSize = Math.max(11, Math.min(18, Number(config.nutritionTitleFontSize ?? config.NutritionTitleFontSize ?? 16) || 16))  
496 - const bodySize = Math.max(8, Math.min(11, Math.floor(titleSize * 0.72)))  
497 - let cursorY = y + pad  
498 -  
499 - const servingsPerContainer = String(config.servingsPerContainer ?? config.ServingsPerContainer ?? '').trim()  
500 - const servingSize = String(config.servingSize ?? config.ServingSize ?? '').trim()  
501 - const calories = String(config.calories ?? config.Calories ?? nutritionFixedField(config, 'calories', 'value') ?? '').trim()  
502 - const rows = [  
503 - ...NUTRITION_FIXED_ITEMS.map((item) => {  
504 - const value = nutritionFixedField(config, item.key, 'value')  
505 - const unit = nutritionFixedField(config, item.key, 'unit')  
506 - return { label: item.label, value: nutritionValueWithLessThan(value, unit) }  
507 - }),  
508 - ...nutritionExtraRows(config).map((r) => ({  
509 - label: r.name || 'Other',  
510 - value: nutritionValueWithLessThan(r.value, r.unit),  
511 - })),  
512 - ]  
513 -  
514 - const drawPair = (label: string, value: string, fs: number, bold = false): boolean => {  
515 - const f = Math.max(8, Math.round(fs))  
516 - const lh = f + 2  
517 - if (cursorY + lh > maxY) return false  
518 - ctx.setFillStyle('#111827')  
519 - ctx.setFontSize(f)  
520 - ctx.setTextAlign('left')  
521 - ctx.fillText(label, x + pad, cursorY + f)  
522 - if (bold) ctx.fillText(label, x + pad + 0.5, cursorY + f)  
523 - if (value) {  
524 - ctx.setTextAlign('right')  
525 - ctx.fillText(value, rightX, cursorY + f)  
526 - if (bold) ctx.fillText(value, rightX + 0.5, cursorY + f)  
527 - ctx.setTextAlign('left')  
528 - }  
529 - cursorY += lh  
530 - return true  
531 - }  
532 -  
533 - ctx.setFillStyle('#ffffff')  
534 - ctx.fillRect(x, y, bw, bh)  
535 - ctx.setStrokeStyle('#111827')  
536 - ctx.setLineWidth(1)  
537 - ctx.strokeRect(x, y, bw, bh)  
538 -  
539 - if (cursorY + titleSize + 2 <= maxY) {  
540 - ctx.setFillStyle('#111827')  
541 - ctx.setFontSize(Math.round(titleSize))  
542 - ctx.setTextAlign('left')  
543 - ctx.fillText('Nutrition Facts', x + pad, cursorY + Math.round(titleSize))  
544 - cursorY += Math.round(titleSize) + 2  
545 - ctx.setLineWidth(1)  
546 - ctx.beginPath()  
547 - ctx.moveTo(x + pad, cursorY)  
548 - ctx.lineTo(rightX, cursorY)  
549 - ctx.stroke()  
550 - cursorY += 2  
551 - }  
552 -  
553 - if (calories) drawPair('Calories', nutritionValueWithLessThan(calories, ''), Math.max(bodySize, 9), true)  
554 - else drawPair('Calories', '', Math.max(bodySize, 9), true)  
555 - drawPair('Servings Per Container', servingsPerContainer, bodySize)  
556 - drawPair('Serving Size', servingSize, bodySize)  
557 - for (const row of rows) {  
558 - if (!drawPair(row.label, row.value, bodySize, true)) break  
559 - }  
560 -  
561 - next() 743 + drawNutritionFactsOnCanvas(ctx, config, x, y, Math.max(40, w || 220), Math.max(80, h || 280))
  744 + finishElement()
562 return 745 return
563 } 746 }
564 747
@@ -581,16 +764,18 @@ function runLabelPreviewCanvasDraw( @@ -581,16 +764,18 @@ function runLabelPreviewCanvasDraw(
581 } 764 }
582 765
583 if (type === 'BARCODE' && d) { 766 if (type === 'BARCODE' && d) {
584 - const orientation = cfgStr(config, ['orientation', 'Orientation'], 'horizontal') 767 + const elementRotation = readElementRotation(el)
  768 + const configOrientation = cfgStr(config, ['orientation', 'Orientation'], 'horizontal').toLowerCase()
  769 + const orientation = elementRotation === 'vertical' ? 'vertical' : configOrientation
585 const showText = String(config.showText ?? config.ShowText ?? 'true').toLowerCase() !== 'false' 770 const showText = String(config.showText ?? config.ShowText ?? 'true').toLowerCase() !== 'false'
586 drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText }) 771 drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText })
587 - next() 772 + finishElement()
588 return 773 return
589 } 774 }
590 775
591 if (type === 'QRCODE' && d && !storedValueLooksLikeImagePath(d)) { 776 if (type === 'QRCODE' && d && !storedValueLooksLikeImagePath(d)) {
592 drawQrCodePreview(ctx, x, y, w || 96, h || 96, d, cfgStr(config, ['errorLevel', 'ErrorLevel'], 'M')) 777 drawQrCodePreview(ctx, x, y, w || 96, h || 96, d, cfgStr(config, ['errorLevel', 'ErrorLevel'], 'M'))
593 - next() 778 + finishElement()
594 return 779 return
595 } 780 }
596 781
@@ -608,11 +793,11 @@ function runLabelPreviewCanvasDraw( @@ -608,11 +793,11 @@ function runLabelPreviewCanvasDraw(
608 } catch (_) { 793 } catch (_) {
609 drawQrBarcodePlaceholder() 794 drawQrBarcodePlaceholder()
610 } 795 }
611 - next() 796 + finishElement()
612 }, 797 },
613 fail: () => { 798 fail: () => {
614 drawQrBarcodePlaceholder() 799 drawQrBarcodePlaceholder()
615 - next() 800 + finishElement()
616 }, 801 },
617 }) 802 })
618 return 803 return
@@ -620,73 +805,81 @@ function runLabelPreviewCanvasDraw( @@ -620,73 +805,81 @@ function runLabelPreviewCanvasDraw(
620 } 805 }
621 806
622 drawQrBarcodePlaceholder() 807 drawQrBarcodePlaceholder()
623 - next() 808 + finishElement()
624 return 809 return
625 } 810 }
626 811
627 - const line = String(el.border || '').toLowerCase()  
628 - if (line === 'line' || line === 'solid') {  
629 - ctx.setStrokeStyle('#111827')  
630 - ctx.setLineWidth(1)  
631 - ctx.strokeRect(x, y, w, h)  
632 - } else if (line === 'dotted') {  
633 - ctx.setStrokeStyle('#9ca3af')  
634 - ctx.setLineWidth(1)  
635 - if (typeof (ctx as any).setLineDash === 'function') {  
636 - ;(ctx as any).setLineDash([3, 3], 0)  
637 - ctx.strokeRect(x, y, w, h)  
638 - ;(ctx as any).setLineDash([], 0)  
639 - } else {  
640 - ctx.strokeRect(x, y, w, h)  
641 - }  
642 - }  
643 -  
644 const text = previewTextForElement(el) 812 const text = previewTextForElement(el)
645 if (text && !isGraphicOnlyType(type)) { 813 if (text && !isGraphicOnlyType(type)) {
646 - const fontSize = readFontSize(config)  
647 - const inverted = readInvertColors(config)  
648 - if (inverted) {  
649 - ctx.setFillStyle('#000000')  
650 - ctx.fillRect(x, y, w, h)  
651 - ctx.setFillStyle('#ffffff')  
652 - } else {  
653 - ctx.setFillStyle(readFillColor(config)) 814 + const rotation = String(el.rotation ?? (el as any).Rotation ?? 'horizontal').toLowerCase()
  815 + const drawAt = (bx: number, by: number, bw: number, bh: number) => {
  816 + const fontSize = readFontSize(config)
  817 + const inverted = readInvertColors(config)
  818 + if (inverted) {
  819 + ctx.setFillStyle('#000000')
  820 + ctx.fillRect(bx, by, bw, bh)
  821 + ctx.setFillStyle('#ffffff')
  822 + } else {
  823 + ctx.setFillStyle(readFillColor(config))
  824 + }
  825 + ctx.setFontSize(fontSize)
  826 + applyCanvasFontFromConfig(ctx, config, fontSize)
  827 + const fontWeight = readFontWeight(config)
  828 + if (typeof (ctx as any).setFontWeight === 'function') {
  829 + ;(ctx as any).setFontWeight(fontWeight === 'bold' ? 'bold' : 'normal')
  830 + }
  831 + const align = readTextAlign(config)
  832 + const fillColor = inverted ? '#ffffff' : readFillColor(config)
  833 + const pad = 2
  834 + const innerW = Math.max(0, bw - pad * 2)
  835 + const innerH = Math.max(fontSize, bh - pad * 2)
  836 + let tx = bx + pad
  837 + if (align === 'center') tx = bx + bw / 2
  838 + else if (align === 'right') tx = bx + bw - pad
  839 + ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left')
  840 + const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15))
  841 + const isDateTimeType = type === 'DATE' || type === 'TIME' || type === 'DURATION'
  842 + const lines = isDateTimeType
  843 + ? [String(text).replace(/\s+/g, ' ').trim()]
  844 + : wrapTextToWidth(text, maxCharsPerLine(innerW, fontSize))
  845 + const maxLines = isDateTimeType
  846 + ? 1
  847 + : innerH >= fontSize
  848 + ? Math.max(1, Math.floor(innerH / lineHeight))
  849 + : lines.length
  850 + const visibleLines = lines.slice(0, maxLines)
  851 + const blockHeight = visibleLines.length * lineHeight
  852 + const verticalAlign = readVerticalAlign(config)
  853 + const verticalOffset = computeVerticalTextBlockOffset(innerH, blockHeight, verticalAlign)
  854 + const startY = by + pad + verticalOffset + fontSize
  855 + visibleLines.forEach((ln, li) => {
  856 + drawStyledTextLine(ctx, ln, tx, startY + li * lineHeight, fontSize, align, config, fillColor)
  857 + })
  858 + ctx.setTextAlign('left')
654 } 859 }
655 - ctx.setFontSize(fontSize)  
656 - const fontWeight = String(config.fontWeight ?? config.FontWeight ?? 'normal').toLowerCase()  
657 - if (typeof (ctx as any).setFontWeight === 'function') {  
658 - ;(ctx as any).setFontWeight(fontWeight === 'bold' || fontWeight === '700' ? 'bold' : 'normal') 860 +
  861 + if (rotation === 'vertical') {
  862 + const anyCtx = ctx as any
  863 + if (typeof anyCtx.save === 'function' && typeof anyCtx.rotate === 'function') {
  864 + anyCtx.save()
  865 + anyCtx.translate(x + w / 2, y + h / 2)
  866 + anyCtx.rotate(-Math.PI / 2)
  867 + drawAt(-h / 2, -w / 2, h, w)
  868 + anyCtx.restore()
  869 + } else {
  870 + drawAt(x, y, w, h)
  871 + }
  872 + } else {
  873 + drawAt(x, y, w, h)
659 } 874 }
660 - const align = readTextAlign(config)  
661 - const pad = 2  
662 - const innerW = Math.max(0, w - pad * 2)  
663 - const innerH = Math.max(fontSize, h - pad * 2)  
664 - let tx = x + pad  
665 - if (align === 'center') tx = x + w / 2  
666 - else if (align === 'right') tx = x + w - pad  
667 - ctx.setTextAlign(align === 'center' ? 'center' : align === 'right' ? 'right' : 'left')  
668 - const lineHeight = fontSize + Math.max(2, Math.round(fontSize * 0.15))  
669 - const isDateTimeType = type === 'DATE' || type === 'TIME' || type === 'DURATION'  
670 - const lines = isDateTimeType  
671 - ? [String(text).replace(/\s+/g, ' ').trim()]  
672 - : wrapTextToWidth(text, maxCharsPerLine(innerW, fontSize))  
673 - const maxLines = isDateTimeType  
674 - ? 1  
675 - : innerH >= fontSize  
676 - ? Math.max(1, Math.floor(innerH / lineHeight))  
677 - : lines.length  
678 - const startY = y + pad + fontSize  
679 - lines.slice(0, maxLines).forEach((ln, li) => {  
680 - ctx.fillText(ln, tx, startY + li * lineHeight)  
681 - })  
682 - ctx.setTextAlign('left')  
683 } 875 }
684 876
685 - next() 877 + finishElement()
686 } 878 }
687 879
688 drawRest(0) 880 drawRest(0)
689 - }) 881 + })
  882 + )
690 } 883 }
691 884
692 /** 885 /**
美国版/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', indent: true, 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', indent: true, dividerAfter: 'thin' },
  22 + { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g', indent: true, 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/print/manager/printerManager.ts
@@ -38,6 +38,7 @@ import { @@ -38,6 +38,7 @@ import {
38 import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl' 38 import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl'
39 import { printRunDiag } from '../printRunDiagnostics' 39 import { printRunDiag } from '../printRunDiagnostics'
40 import { adaptSystemLabelTemplate } from '../systemTemplateAdapter' 40 import { adaptSystemLabelTemplate } from '../systemTemplateAdapter'
  41 +import { templateRequiresCanvasStyleFidelity } from '../nativeTemplateElementSupport'
41 import { hydrateSystemTemplateImagesForPrint } from '../hydrateTemplateImagesForPrint' 42 import { hydrateSystemTemplateImagesForPrint } from '../hydrateTemplateImagesForPrint'
42 import { TEST_PRINT_SYSTEM_TEMPLATE, TEST_PRINT_TEMPLATE_DATA } from '../templates/testPrintTemplate' 43 import { TEST_PRINT_SYSTEM_TEMPLATE, TEST_PRINT_TEMPLATE_DATA } from '../templates/testPrintTemplate'
43 import { describePrinterCandidate, getPrinterDriverByKey, resolvePrinterDriver } from './driverRegistry' 44 import { describePrinterCandidate, getPrinterDriverByKey, resolvePrinterDriver } from './driverRegistry'
@@ -1038,6 +1039,7 @@ export async function printSystemTemplateForCurrentPrinter ( @@ -1038,6 +1039,7 @@ export async function printSystemTemplateForCurrentPrinter (
1038 !!canvasRaster 1039 !!canvasRaster
1039 && templateHasQrDataForCommandPrint(template) 1040 && templateHasQrDataForCommandPrint(template)
1040 && !templateHasUnsupportedElementsForCommandPrint(template) 1041 && !templateHasUnsupportedElementsForCommandPrint(template)
  1042 + && !templateRequiresCanvasStyleFidelity(template)
1041 1043
1042 if (canvasRaster && !bypassCanvasRasterForQr) { 1044 if (canvasRaster && !bypassCanvasRasterForQr) {
1043 if (onProgress) onProgress(1) 1045 if (onProgress) onProgress(1)
美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
@@ -4,6 +4,13 @@ import type { @@ -4,6 +4,13 @@ import type {
4 SystemTemplateTextAlign, 4 SystemTemplateTextAlign,
5 } from './types/printer' 5 } from './types/printer'
6 import { readInvertColors } from '../invertColorsConfig' 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'
7 14
8 declare const plus: any 15 declare const plus: any
9 16
@@ -188,6 +195,11 @@ export function shouldRasterizeTextElement ( @@ -188,6 +195,11 @@ export function shouldRasterizeTextElement (
188 */ 195 */
189 if (normalizedType === 'TEXT_PRICE') return true 196 if (normalizedType === 'TEXT_PRICE') return true
190 if (/[€£¥¥éÉáàâäãåæçèêëìíîïñòóôöõøùúûüýÿœšž]/.test(normalizedText)) return true 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
191 return /[^\x20-\x7E]/.test(normalizedText) 203 return /[^\x20-\x7E]/.test(normalizedText)
192 } 204 }
193 205
@@ -224,9 +236,25 @@ export function createTextBitmapPatch (params: { @@ -224,9 +236,25 @@ export function createTextBitmapPatch (params: {
224 paint.setSubpixelText(true) 236 paint.setSubpixelText(true)
225 const fontSizeDots = Math.max(14, pxToDots(Number(config.fontSize || 14), dpi)) 237 const fontSizeDots = Math.max(14, pxToDots(Number(config.fontSize || 14), dpi))
226 paint.setTextSize(fontSizeDots) 238 paint.setTextSize(fontSizeDots)
227 - const isBold = String(config.fontWeight || '').toLowerCase() === 'bold' || String(element.type || '').toUpperCase() === 'TEXT_PRICE'  
228 - paint.setFakeBoldText(isBold)  
229 - 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)
230 258
231 const maxTextWidth = Math.max(8, contentWidth) 259 const maxTextWidth = Math.max(8, contentWidth)
232 const lines = splitTextLines(text, paint, maxTextWidth) 260 const lines = splitTextLines(text, paint, maxTextWidth)
@@ -236,10 +264,13 @@ export function createTextBitmapPatch (params: { @@ -236,10 +264,13 @@ export function createTextBitmapPatch (params: {
236 Math.ceil(Math.abs(Number(fontMetrics.top)) + Math.abs(Number(fontMetrics.bottom)) + 2) 264 Math.ceil(Math.abs(Number(fontMetrics.top)) + Math.abs(Number(fontMetrics.bottom)) + 2)
237 ) 265 )
238 const totalHeight = lines.length * lineHeight 266 const totalHeight = lines.length * lineHeight
239 - const isCenteredVertically = String(element.type || '').toUpperCase() === 'TEXT_PRICE'  
240 - const topOffset = isCenteredVertically  
241 - ? Math.max(TEXT_PADDING_DOTS, Math.floor((height - totalHeight) / 2))  
242 - : 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 + }
243 274
244 for (let i = 0; i < lines.length; i++) { 275 for (let i = 0; i < lines.length; i++) {
245 const line = lines[i] 276 const line = lines[i]
@@ -252,6 +283,15 @@ export function createTextBitmapPatch (params: { @@ -252,6 +283,15 @@ export function createTextBitmapPatch (params: {
252 } 283 }
253 const baseline = topOffset + i * lineHeight - Number(fontMetrics.top) 284 const baseline = topOffset + i * lineHeight - Number(fontMetrics.top)
254 canvas.drawText(line, drawX, baseline, paint) 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 + }
255 } 295 }
256 296
257 const image = bitmapToMonochromeImage(bitmap) 297 const image = bitmapToMonochromeImage(bitmap)
美国版/Food Labeling Management App UniApp/src/utils/print/nativeTemplateElementSupport.ts
@@ -11,6 +11,16 @@ import { formatBarcodeValueForTsc, normalizeBarcodeType } from &#39;../barcodeFormat @@ -11,6 +11,16 @@ import { formatBarcodeValueForTsc, normalizeBarcodeType } from &#39;../barcodeFormat
11 import { applyTemplateData } from './templateRenderer' 11 import { applyTemplateData } from './templateRenderer'
12 import { resolveElementDateTimeDisplay } from '../labelPreview/printInputOffset' 12 import { resolveElementDateTimeDisplay } from '../labelPreview/printInputOffset'
13 import { readInvertColors } from '../invertColorsConfig' 13 import { readInvertColors } from '../invertColorsConfig'
  14 +import { normalizeTemplatePrintOrientation } from '../labelPreview/normalizePreviewTemplate'
  15 +import {
  16 + readElementBorder,
  17 + readElementRotation,
  18 + readFontStyle,
  19 + readFontWeight,
  20 + readTextDecoration,
  21 + readVerticalAlign,
  22 +} from '../textElementLayout'
  23 +import { normalizeLabelEditorFontFamily, LABEL_EDITOR_FONT_FAMILY } from '../labelEditorFonts'
14 24
15 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean { 25 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean {
16 const type = String(el.type || '').toUpperCase() 26 const type = String(el.type || '').toUpperCase()
@@ -188,3 +198,50 @@ export function templateHasUnsupportedNativeFastElements (template: SystemLabelT @@ -188,3 +198,50 @@ export function templateHasUnsupportedNativeFastElements (template: SystemLabelT
188 } 198 }
189 return false 199 return false
190 } 200 }
  201 +
  202 +/** 元素边框 / 黑底白字 / 斜体下划线 / 自定义字体 / 垂直对齐 / 竖排 / 横打等,原生路径无法完整还原时须走 canvas 光栅 */
  203 +export function templateRequiresCanvasStyleFidelity (template: SystemLabelTemplate): boolean {
  204 + const paperBorder = String(template.border ?? (template as any).Border ?? '').trim().toLowerCase()
  205 + if (paperBorder === 'line' || paperBorder === 'dotted' || paperBorder === 'solid') {
  206 + return true
  207 + }
  208 + if (normalizeTemplatePrintOrientation(template.printOrientation) === 'horizontal') {
  209 + return true
  210 + }
  211 +
  212 + for (const el of template.elements || []) {
  213 + const border = readElementBorder(el)
  214 + const type = String(el.type || '').toUpperCase()
  215 + if (border === 'line' || border === 'dotted' || border === 'solid') {
  216 + return true
  217 + }
  218 + if (readElementRotation(el) === 'vertical') {
  219 + return true
  220 + }
  221 + const cfg = (el.config || {}) as Record<string, unknown>
  222 + if (readInvertColors(cfg)) {
  223 + return true
  224 + }
  225 + if (readFontStyle(cfg) === 'italic') {
  226 + return true
  227 + }
  228 + if (readTextDecoration(cfg) === 'underline') {
  229 + return true
  230 + }
  231 + if (readFontWeight(cfg) === 'bold') {
  232 + return true
  233 + }
  234 + if (readVerticalAlign(cfg) !== 'top') {
  235 + return true
  236 + }
  237 + const normalizedFont = normalizeLabelEditorFontFamily(cfg.fontFamily ?? cfg.FontFamily)
  238 + if (normalizedFont && normalizedFont !== LABEL_EDITOR_FONT_FAMILY) {
  239 + return true
  240 + }
  241 + /** 营养表使用独立排版,原生与 canvas 字体/间距易不一致 */
  242 + if (type === 'NUTRITION') {
  243 + return true
  244 + }
  245 + }
  246 + return false
  247 +}
美国版/Food Labeling Management App UniApp/src/utils/print/systemTemplateAdapter.ts
@@ -6,6 +6,7 @@ import { @@ -6,6 +6,7 @@ import {
6 shouldRasterizeTextElement, 6 shouldRasterizeTextElement,
7 } from './nativeBitmapPatch' 7 } from './nativeBitmapPatch'
8 import { applyTemplateData } from './templateRenderer' 8 import { applyTemplateData } from './templateRenderer'
  9 +import { readElementBorder } from '../textElementLayout'
9 import type { 10 import type {
10 EscTemplateItem, 11 EscTemplateItem,
11 LabelTemplateData, 12 LabelTemplateData,
@@ -352,7 +353,7 @@ function pushTemplatePaperBorderIfNeeded ( @@ -352,7 +353,7 @@ function pushTemplatePaperBorderIfNeeded (
352 y: 0, 353 y: 0,
353 width: Math.max(1, pxToDots(templateWidthPx(template), dpi)), 354 width: Math.max(1, pxToDots(templateWidthPx(template), dpi)),
354 height: Math.max(1, pxToDots(templateHeightPx(template), dpi)), 355 height: Math.max(1, pxToDots(templateHeightPx(template), dpi)),
355 - lineWidth: border === 'dotted' ? 1 : 2, 356 + lineWidth: border === 'dotted' ? 2 : 3,
356 }) 357 })
357 } 358 }
358 359
@@ -361,9 +362,7 @@ function pushElementBorderBoxIfNeeded ( @@ -361,9 +362,7 @@ function pushElementBorderBoxIfNeeded (
361 element: SystemTemplateElementBase, 362 element: SystemTemplateElementBase,
362 dpi: number 363 dpi: number
363 ) { 364 ) {
364 - const type = String(element.type || '').toUpperCase()  
365 - if (type === 'BLANK') return  
366 - const border = String(element.border || '').toLowerCase() 365 + const border = readElementBorder(element)
367 if (border !== 'line' && border !== 'dotted') return 366 if (border !== 'line' && border !== 'dotted') return
368 items.push({ 367 items.push({
369 type: 'box', 368 type: 'box',
@@ -550,16 +549,6 @@ function buildTscTemplate ( @@ -550,16 +549,6 @@ function buildTscTemplate (
550 if (bitmapPatch) items.push(bitmapPatch) 549 if (bitmapPatch) items.push(bitmapPatch)
551 return 550 return
552 } 551 }
553 -  
554 - if (type === 'BLANK' && String(element.border || '').toLowerCase() === 'line') {  
555 - items.push({  
556 - type: 'bar',  
557 - x: pxToDots(element.x, dpi),  
558 - y: pxToDots(element.y, dpi),  
559 - width: Math.max(1, pxToDots(element.width, dpi)),  
560 - height: Math.max(1, pxToDots(element.height || 1, dpi)),  
561 - })  
562 - }  
563 }) 552 })
564 553
565 const maxBottomDots = items.reduce( 554 const maxBottomDots = items.reduce(
@@ -650,7 +639,7 @@ function buildEscTemplate ( @@ -650,7 +639,7 @@ function buildEscTemplate (
650 return 639 return
651 } 640 }
652 641
653 - if (type === 'BLANK' && String(element.border || '').toLowerCase() === 'line') { 642 + if (type === 'BLANK' && readElementBorder(element) === 'line') {
654 items.push({ 643 items.push({
655 type: 'rule', 644 type: 'rule',
656 width: clamp(element.width / 8, 8, 48), 645 width: clamp(element.width / 8, 8, 48),
美国版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts
@@ -22,6 +22,7 @@ import { @@ -22,6 +22,7 @@ import {
22 import { 22 import {
23 normalizeTemplateForNativeFastJob, 23 normalizeTemplateForNativeFastJob,
24 templateHasUnsupportedNativeFastElements, 24 templateHasUnsupportedNativeFastElements,
  25 + templateRequiresCanvasStyleFidelity,
25 } from './print/nativeTemplateElementSupport' 26 } from './print/nativeTemplateElementSupport'
26 import { 27 import {
27 ensureTemplateHeightCoversElements, 28 ensureTemplateHeightCoversElements,
@@ -364,6 +365,7 @@ async function printReprintTemplateWithPreviewStrategy ( @@ -364,6 +365,7 @@ async function printReprintTemplateWithPreviewStrategy (
364 canPrintCurrentLabelViaNativeFastJob() 365 canPrintCurrentLabelViaNativeFastJob()
365 && isTemplateWithinNativeFastPrintBounds(tmplSized) 366 && isTemplateWithinNativeFastPrintBounds(tmplSized)
366 && !templateHasUnsupportedNativeFastElements(tmplForNative) 367 && !templateHasUnsupportedNativeFastElements(tmplForNative)
  368 + && !templateRequiresCanvasStyleFidelity(tmplSized)
367 369
368 const printQty = options.printQty ?? 1 370 const printQty = options.printQty ?? 1
369 371
美国版/Food Labeling Management App UniApp/src/utils/smartScaleService.ts 0 → 100644
  1 +import type { SmartScaleReadKind } from './weightElement'
  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/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' {
  65 + const v = String(el.rotation ?? el.Rotation ?? 'horizontal').trim().toLowerCase()
  66 + return v === 'vertical' ? 'vertical' : 'horizontal'
  67 +}
美国版/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' ? 'Enter weight or read from scale' : 'Net weight'
  20 +}
  21 +
  22 +export type SmartScaleReadKind = 'tared' | 'gross'
美国版/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 Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/Label/LabelTemplatePreviewDto.cs
@@ -5,7 +5,7 @@ using FoodLabeling.Application.Contracts.Dtos.LabelTemplate; @@ -5,7 +5,7 @@ using FoodLabeling.Application.Contracts.Dtos.LabelTemplate;
5 namespace FoodLabeling.Application.Contracts.Dtos.Label; 5 namespace FoodLabeling.Application.Contracts.Dtos.Label;
6 6
7 /// <summary> 7 /// <summary>
8 -/// 预览输出:与前端 LabelCanvas/LabelPreviewOnly 的 LabelTemplate 结构尽量一致 8 +/// Preview payload aligned with frontend LabelCanvas / LabelPreviewOnly template shape.
9 /// </summary> 9 /// </summary>
10 public class LabelTemplatePreviewDto 10 public class LabelTemplatePreviewDto
11 { 11 {
@@ -36,7 +36,7 @@ public class LabelTemplatePreviewDto @@ -36,7 +36,7 @@ public class LabelTemplatePreviewDto
36 [JsonPropertyName("showGrid")] 36 [JsonPropertyName("showGrid")]
37 public bool ShowGrid { get; set; } 37 public bool ShowGrid { get; set; }
38 38
39 - /// <summary>整标签外框:none / line / dotted</summary> 39 + /// <summary>Label outer border: none / line / dotted</summary>
40 [JsonPropertyName("border")] 40 [JsonPropertyName("border")]
41 public string Border { get; set; } = "none"; 41 public string Border { get; set; } = "none";
42 42
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application.Contracts/Dtos/LabelTemplate/LabelTemplateElementDto.cs
@@ -3,7 +3,7 @@ using System.Text.Json.Serialization; @@ -3,7 +3,7 @@ using System.Text.Json.Serialization;
3 namespace FoodLabeling.Application.Contracts.Dtos.LabelTemplate; 3 namespace FoodLabeling.Application.Contracts.Dtos.LabelTemplate;
4 4
5 /// <summary> 5 /// <summary>
6 -/// 模板元素(对齐 editor JSON:id/type/typeAdd/elementName/x/y/width/height/rotation/border/config 等) 6 +/// Label template element (id, type, typeAdd, elementName, geometry, border, config, etc.).
7 /// </summary> 7 /// </summary>
8 public class LabelTemplateElementDto 8 public class LabelTemplateElementDto
9 { 9 {
@@ -13,9 +13,6 @@ public class LabelTemplateElementDto @@ -13,9 +13,6 @@ public class LabelTemplateElementDto
13 [JsonPropertyName("type")] 13 [JsonPropertyName("type")]
14 public string ElementType { get; set; } = string.Empty; 14 public string ElementType { get; set; } = string.Empty;
15 15
16 - /// <summary>  
17 - /// 元素附加类型(分组前缀 + 控件名),如 label_Duration  
18 - /// </summary>  
19 [JsonPropertyName("typeAdd")] 16 [JsonPropertyName("typeAdd")]
20 public string? TypeAdd { get; set; } 17 public string? TypeAdd { get; set; }
21 18
@@ -46,9 +43,6 @@ public class LabelTemplateElementDto @@ -46,9 +43,6 @@ public class LabelTemplateElementDto
46 [JsonPropertyName("orderNum")] 43 [JsonPropertyName("orderNum")]
47 public int OrderNum { get; set; } 44 public int OrderNum { get; set; }
48 45
49 - /// <summary>  
50 - /// 值来源:FIXED / AUTO_DB / PRINT_INPUT  
51 - /// </summary>  
52 [JsonPropertyName("valueSourceType")] 46 [JsonPropertyName("valueSourceType")]
53 public string ValueSourceType { get; set; } = "FIXED"; 47 public string ValueSourceType { get; set; } = "FIXED";
54 48
@@ -64,10 +58,6 @@ public class LabelTemplateElementDto @@ -64,10 +58,6 @@ public class LabelTemplateElementDto
64 [JsonPropertyName("isRequiredInput")] 58 [JsonPropertyName("isRequiredInput")]
65 public bool IsRequiredInput { get; set; } 59 public bool IsRequiredInput { get; set; }
66 60
67 - /// <summary>  
68 - /// 元素配置  
69 - /// </summary>  
70 [JsonPropertyName("config")] 61 [JsonPropertyName("config")]
71 public object? ConfigJson { get; set; } 62 public object? ConfigJson { get; set; }
72 } 63 }
73 -  
美国版/Food Labeling Management Code/Yi.Abp.Net8/module/food-labeling-us/FoodLabeling.Application/Helpers/LabelTemplateListItemsHelper.cs
  1 +using System.Text.RegularExpressions;
1 using FoodLabeling.Application.Services.DbModels; 2 using FoodLabeling.Application.Services.DbModels;
2 using SqlSugar; 3 using SqlSugar;
3 4
4 namespace FoodLabeling.Application.Helpers; 5 namespace FoodLabeling.Application.Helpers;
5 6
6 /// <summary> 7 /// <summary>
7 -/// 标签模板列表「Items」列:汇总模板内各控件展示名(按 <c>OrderNum</c> 排序)。 8 +/// Builds template list "Items" column from element display names (ordered by <c>OrderNum</c>).
8 /// </summary> 9 /// </summary>
9 public static class LabelTemplateListItemsHelper 10 public static class LabelTemplateListItemsHelper
10 { 11 {
11 - /// <summary>  
12 - /// 批量解析模板控件名称。Key = <c>fl_label_template.Id</c>。  
13 - /// </summary> 12 + private static readonly HashSet<string> SharedTemplateAndLabelLabels = new(StringComparer.Ordinal)
  13 + {
  14 + "Text",
  15 + "QR Code",
  16 + "Barcode",
  17 + "Price",
  18 + "Image",
  19 + };
  20 +
  21 + private static readonly Dictionary<string, string> CanonicalPaletteLabels =
  22 + new(StringComparer.OrdinalIgnoreCase)
  23 + {
  24 + ["text"] = "Text",
  25 + ["qr code"] = "QR Code",
  26 + ["qrcode"] = "QR Code",
  27 + ["barcode"] = "Barcode",
  28 + ["price"] = "Price",
  29 + ["image"] = "Image",
  30 + ["logo"] = "Logo",
  31 + ["blank space"] = "Blank Space",
  32 + ["blankspace"] = "Blank Space",
  33 + ["label name"] = "Label Name",
  34 + ["labelname"] = "Label Name",
  35 + ["nutrition facts"] = "Nutrition Facts",
  36 + ["duration date"] = "Duration Date",
  37 + ["duration time"] = "Duration Time",
  38 + ["duration"] = "Duration",
  39 + ["label type"] = "Label Type",
  40 + ["how-to"] = "How-to",
  41 + ["howto"] = "How-to",
  42 + ["expiration alert"] = "Expiration Alert",
  43 + ["company"] = "Company",
  44 + ["employee"] = "Employee",
  45 + ["current date"] = "Current Date",
  46 + ["current time"] = "Current Time",
  47 + ["label id"] = "Label ID",
  48 + ["labelid"] = "Label ID",
  49 + ["weight"] = "Weight",
  50 + ["number"] = "Number",
  51 + ["date & time"] = "Date & Time",
  52 + ["datetime"] = "Date & Time",
  53 + ["multiple options"] = "Multiple Options",
  54 + };
  55 +
  56 + private static readonly Dictionary<string, string> LibraryGroupPrefixToPanelTitle =
  57 + new(StringComparer.OrdinalIgnoreCase)
  58 + {
  59 + ["template"] = "For Template",
  60 + ["label"] = "For Label",
  61 + };
  62 +
14 public static async Task<Dictionary<string, TemplateItemsDisplay>> ResolveTemplateItemsMapAsync( 63 public static async Task<Dictionary<string, TemplateItemsDisplay>> ResolveTemplateItemsMapAsync(
15 ISqlSugarClient db, 64 ISqlSugarClient db,
16 IReadOnlyList<string> templateIds) 65 IReadOnlyList<string> templateIds)
@@ -52,7 +101,7 @@ public static class LabelTemplateListItemsHelper @@ -52,7 +101,7 @@ public static class LabelTemplateListItemsHelper
52 result[group.Key] = new TemplateItemsDisplay 101 result[group.Key] = new TemplateItemsDisplay
53 { 102 {
54 ItemNames = names, 103 ItemNames = names,
55 - Items = names.Count > 0 ? string.Join(", ", names) : "" 104 + Items = names.Count > 0 ? string.Join(", ", names) : "None"
56 }; 105 };
57 } 106 }
58 107
@@ -67,18 +116,33 @@ public static class LabelTemplateListItemsHelper @@ -67,18 +116,33 @@ public static class LabelTemplateListItemsHelper
67 return result; 116 return result;
68 } 117 }
69 118
70 - /// <summary>  
71 - /// 控件展示名:优先 <c>ElementName</c>,其次 <c>TypeAdd</c> 后缀,最后按 <c>ElementType</c> 映射。  
72 - /// </summary>  
73 public static string ResolveElementDisplayName(string? elementName, string? typeAdd, string? elementType) 119 public static string ResolveElementDisplayName(string? elementName, string? typeAdd, string? elementType)
74 { 120 {
75 - var name = elementName?.Trim();  
76 - if (!string.IsNullOrWhiteSpace(name)) 121 + var slug = elementName?.Trim() ?? string.Empty;
  122 + var ta = typeAdd?.Trim();
  123 + if (!string.IsNullOrWhiteSpace(ta))
77 { 124 {
78 - return name; 125 + var parsed = TryParseComposedTypeAdd(ta);
  126 + if (parsed != null)
  127 + {
  128 + var (prefix, paletteLabel) = parsed.Value;
  129 + var ordinal = FormatElementNameOrdinalSuffix(slug, paletteLabel);
  130 + var core = $"{paletteLabel}{ordinal}";
  131 + if (LibraryGroupPrefixToPanelTitle.TryGetValue(prefix, out var panelTitle)
  132 + && SharedTemplateAndLabelLabels.Contains(paletteLabel))
  133 + {
  134 + return $"{core} ({panelTitle})";
  135 + }
  136 +
  137 + return core;
  138 + }
  139 + }
  140 +
  141 + if (!string.IsNullOrWhiteSpace(slug))
  142 + {
  143 + return slug;
79 } 144 }
80 145
81 - var ta = typeAdd?.Trim();  
82 if (!string.IsNullOrWhiteSpace(ta)) 146 if (!string.IsNullOrWhiteSpace(ta))
83 { 147 {
84 var idx = ta.IndexOf('_'); 148 var idx = ta.IndexOf('_');
@@ -93,6 +157,63 @@ public static class LabelTemplateListItemsHelper @@ -93,6 +157,63 @@ public static class LabelTemplateListItemsHelper
93 return MapElementTypeToDisplayLabel(elementType); 157 return MapElementTypeToDisplayLabel(elementType);
94 } 158 }
95 159
  160 + private static (string prefix, string paletteLabel)? TryParseComposedTypeAdd(string typeAdd)
  161 + {
  162 + var m = Regex.Match(typeAdd.Trim(), @"^(template|label|auto|print)_(.+)$", RegexOptions.IgnoreCase);
  163 + if (!m.Success)
  164 + {
  165 + return null;
  166 + }
  167 +
  168 + var prefix = m.Groups[1].Value.ToLowerInvariant();
  169 + var paletteLabel = CanonicalizePaletteLabel(m.Groups[2].Value.Trim());
  170 + return string.IsNullOrWhiteSpace(paletteLabel) ? null : (prefix, paletteLabel);
  171 + }
  172 +
  173 + private static string CanonicalizePaletteLabel(string raw)
  174 + {
  175 + var trimmed = raw.Trim();
  176 + if (string.IsNullOrWhiteSpace(trimmed))
  177 + {
  178 + return trimmed;
  179 + }
  180 +
  181 + if (CanonicalPaletteLabels.TryGetValue(trimmed, out var mapped))
  182 + {
  183 + return mapped;
  184 + }
  185 +
  186 + var slug = SlugPaletteLabel(trimmed);
  187 + if (CanonicalPaletteLabels.TryGetValue(slug, out mapped))
  188 + {
  189 + return mapped;
  190 + }
  191 +
  192 + return trimmed;
  193 + }
  194 +
  195 + private static string SlugPaletteLabel(string paletteLabel)
  196 + {
  197 + return Regex.Replace(paletteLabel.Trim().ToLowerInvariant(), @"[^a-z0-9]+", string.Empty);
  198 + }
  199 +
  200 + private static string FormatElementNameOrdinalSuffix(string slug, string paletteLabel)
  201 + {
  202 + var baseSlug = SlugPaletteLabel(paletteLabel);
  203 + if (string.IsNullOrWhiteSpace(slug) || string.IsNullOrWhiteSpace(baseSlug))
  204 + {
  205 + return string.Empty;
  206 + }
  207 +
  208 + if (slug.Equals(baseSlug, StringComparison.OrdinalIgnoreCase))
  209 + {
  210 + return string.Empty;
  211 + }
  212 +
  213 + var m = Regex.Match(slug, $"^{Regex.Escape(baseSlug)}(\\d+)$", RegexOptions.IgnoreCase);
  214 + return m.Success ? $" {m.Groups[1].Value}" : string.Empty;
  215 + }
  216 +
96 private static string MapElementTypeToDisplayLabel(string? elementType) 217 private static string MapElementTypeToDisplayLabel(string? elementType)
97 { 218 {
98 var t = (elementType ?? string.Empty).Trim().ToUpperInvariant(); 219 var t = (elementType ?? string.Empty).Trim().ToUpperInvariant();
@@ -117,9 +238,9 @@ public static class LabelTemplateListItemsHelper @@ -117,9 +238,9 @@ public static class LabelTemplateListItemsHelper
117 238
118 public sealed class TemplateItemsDisplay 239 public sealed class TemplateItemsDisplay
119 { 240 {
120 - public static TemplateItemsDisplay Empty { get; } = new() { Items = "", ItemNames = new List<string>() }; 241 + public static TemplateItemsDisplay Empty { get; } = new() { Items = "None", ItemNames = new List<string>() };
121 242
122 - public string Items { get; init; } = ""; 243 + public string Items { get; init; } = "None";
123 244
124 public List<string> ItemNames { get; init; } = new(); 245 public List<string> ItemNames { get; init; } = new();
125 } 246 }
美国版/Food Labeling Management Platform/build/assets/index-BFajuDEY.css renamed to 美国版/Food Labeling Management Platform/build/assets/index-C6CSIunP.css
1 -.rdp{--rdp-cell-size: 40px;--rdp-caption-font-size: 18px;--rdp-accent-color: #0000ff;--rdp-background-color: #e7edff;--rdp-accent-color-dark: #3003e1;--rdp-background-color-dark: #180270;--rdp-outline: 2px solid var(--rdp-accent-color);--rdp-outline-selected: 3px solid var(--rdp-accent-color);--rdp-selected-color: #fff;margin:1em}.rdp-vhidden{box-sizing:border-box;padding:0;margin:0;background:transparent;border:0;-moz-appearance:none;-webkit-appearance:none;appearance:none;position:absolute!important;top:0;width:1px!important;height:1px!important;padding:0!important;overflow:hidden!important;clip:rect(1px,1px,1px,1px)!important;border:0!important}.rdp-button_reset{appearance:none;position:relative;margin:0;padding:0;cursor:default;color:inherit;background:none;font:inherit;-moz-appearance:none;-webkit-appearance:none}.rdp-button_reset:focus-visible{outline:none}.rdp-button{border:2px solid transparent}.rdp-button[disabled]:not(.rdp-day_selected){opacity:.25}.rdp-button:not([disabled]){cursor:pointer}.rdp-button:focus-visible:not([disabled]){color:inherit;background-color:var(--rdp-background-color);border:var(--rdp-outline)}.rdp-button:hover:not([disabled]):not(.rdp-day_selected){background-color:var(--rdp-background-color)}.rdp-months{display:flex}.rdp-month{margin:0 1em}.rdp-month:first-child{margin-left:0}.rdp-month:last-child{margin-right:0}.rdp-table{margin:0;max-width:calc(var(--rdp-cell-size) * 7);border-collapse:collapse}.rdp-with_weeknumber .rdp-table{max-width:calc(var(--rdp-cell-size) * 8);border-collapse:collapse}.rdp-caption{display:flex;align-items:center;justify-content:space-between;padding:0;text-align:left}.rdp-multiple_months .rdp-caption{position:relative;display:block;text-align:center}.rdp-caption_dropdowns{position:relative;display:inline-flex}.rdp-caption_label{position:relative;z-index:1;display:inline-flex;align-items:center;margin:0;padding:0 .25em;white-space:nowrap;color:currentColor;border:0;border:2px solid transparent;font-family:inherit;font-size:var(--rdp-caption-font-size);font-weight:700}.rdp-nav{white-space:nowrap}.rdp-multiple_months .rdp-caption_start .rdp-nav{position:absolute;top:50%;left:0;transform:translateY(-50%)}.rdp-multiple_months .rdp-caption_end .rdp-nav{position:absolute;top:50%;right:0;transform:translateY(-50%)}.rdp-nav_button{display:inline-flex;align-items:center;justify-content:center;width:var(--rdp-cell-size);height:var(--rdp-cell-size);padding:.25em;border-radius:100%}.rdp-dropdown_year,.rdp-dropdown_month{position:relative;display:inline-flex;align-items:center}.rdp-dropdown{appearance:none;position:absolute;z-index:2;top:0;bottom:0;left:0;width:100%;margin:0;padding:0;cursor:inherit;opacity:0;border:none;background-color:transparent;font-family:inherit;font-size:inherit;line-height:inherit}.rdp-dropdown[disabled]{opacity:unset;color:unset}.rdp-dropdown:focus-visible:not([disabled])+.rdp-caption_label{background-color:var(--rdp-background-color);border:var(--rdp-outline);border-radius:6px}.rdp-dropdown_icon{margin:0 0 0 5px}.rdp-head{border:0}.rdp-head_row,.rdp-row{height:100%}.rdp-head_cell{vertical-align:middle;font-size:.75em;font-weight:700;text-align:center;height:100%;height:var(--rdp-cell-size);padding:0;text-transform:uppercase}.rdp-tbody{border:0}.rdp-tfoot{margin:.5em}.rdp-cell{width:var(--rdp-cell-size);height:100%;height:var(--rdp-cell-size);padding:0;text-align:center}.rdp-weeknumber{font-size:.75em}.rdp-weeknumber,.rdp-day{display:flex;overflow:hidden;align-items:center;justify-content:center;box-sizing:border-box;width:var(--rdp-cell-size);max-width:var(--rdp-cell-size);height:var(--rdp-cell-size);margin:0;border:2px solid transparent;border-radius:100%}.rdp-day_today:not(.rdp-day_outside){font-weight:700}.rdp-day_selected,.rdp-day_selected:focus-visible,.rdp-day_selected:hover{color:var(--rdp-selected-color);opacity:1;background-color:var(--rdp-accent-color)}.rdp-day_outside{opacity:.5}.rdp-day_selected:focus-visible{outline:var(--rdp-outline);outline-offset:2px;z-index:1}.rdp:not([dir=rtl]) .rdp-day_range_start:not(.rdp-day_range_end){border-top-right-radius:0;border-bottom-right-radius:0}.rdp:not([dir=rtl]) .rdp-day_range_end:not(.rdp-day_range_start){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_start:not(.rdp-day_range_end){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_end:not(.rdp-day_range_start){border-top-right-radius:0;border-bottom-right-radius:0}.rdp-day_range_end.rdp-day_range_start{border-radius:100%}.rdp-day_range_middle{border-radius:0}/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-space-x-reverse: 0;--tw-border-style: solid;--tw-gradient-position: initial;--tw-gradient-from: #0000;--tw-gradient-via: #0000;--tw-gradient-to: #0000;--tw-gradient-stops: initial;--tw-gradient-via-stops: initial;--tw-gradient-from-position: 0%;--tw-gradient-via-position: 50%;--tw-gradient-to-position: 100%;--tw-leading: initial;--tw-font-weight: initial;--tw-tracking: initial;--tw-ordinal: initial;--tw-slashed-zero: initial;--tw-numeric-figure: initial;--tw-numeric-spacing: initial;--tw-numeric-fraction: initial;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000;--tw-outline-style: solid;--tw-backdrop-blur: initial;--tw-backdrop-brightness: initial;--tw-backdrop-contrast: initial;--tw-backdrop-grayscale: initial;--tw-backdrop-hue-rotate: initial;--tw-backdrop-invert: initial;--tw-backdrop-opacity: initial;--tw-backdrop-saturate: initial;--tw-backdrop-sepia: initial;--tw-duration: initial;--tw-ease: initial;--tw-scale-x: 1;--tw-scale-y: 1;--tw-scale-z: 1}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100: oklch(.936 .032 17.717);--color-red-300: oklch(.808 .114 19.571);--color-red-400: oklch(.704 .191 22.216);--color-red-500: oklch(.637 .237 25.331);--color-red-600: oklch(.577 .245 27.325);--color-red-700: oklch(.505 .213 27.518);--color-red-900: oklch(.396 .141 25.723);--color-orange-50: oklch(.98 .016 73.684);--color-orange-200: oklch(.901 .076 70.697);--color-orange-500: oklch(.705 .213 47.604);--color-orange-700: oklch(.553 .195 38.402);--color-yellow-400: oklch(.852 .199 91.936);--color-yellow-500: oklch(.795 .184 86.047);--color-green-100: oklch(.962 .044 156.743);--color-green-500: oklch(.723 .219 149.579);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-emerald-50: oklch(.979 .021 166.113);--color-emerald-600: oklch(.596 .145 163.225);--color-blue-50: oklch(.97 .014 254.604);--color-blue-100: oklch(.932 .032 255.585);--color-blue-200: oklch(.882 .059 254.128);--color-blue-300: oklch(.809 .105 251.813);--color-blue-400: oklch(.707 .165 254.624);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-blue-800: oklch(.424 .199 265.638);--color-blue-900: oklch(.379 .146 265.522);--color-indigo-50: oklch(.962 .018 272.314);--color-indigo-600: oklch(.511 .262 276.966);--color-gray-50: oklch(.985 .002 247.839);--color-gray-100: oklch(.967 .003 264.542);--color-gray-200: oklch(.928 .006 264.531);--color-gray-300: oklch(.872 .01 258.338);--color-gray-400: oklch(.707 .022 261.325);--color-gray-500: oklch(.551 .027 264.364);--color-gray-600: oklch(.446 .03 256.802);--color-gray-700: oklch(.373 .034 259.733);--color-gray-800: oklch(.278 .033 256.848);--color-gray-900: oklch(.21 .034 264.665);--color-black: #000;--color-white: #fff;--spacing: .25rem;--container-xs: 20rem;--container-md: 28rem;--container-lg: 32rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-base--line-height: 1.5 ;--text-lg: 1.125rem;--text-lg--line-height: calc(1.75 / 1.125);--text-xl: 1.25rem;--text-xl--line-height: calc(1.75 / 1.25);--text-2xl: 1.5rem;--text-2xl--line-height: calc(2 / 1.5);--text-3xl: 1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semibold: 600;--font-weight-bold: 700;--tracking-wide: .025em;--tracking-wider: .05em;--leading-tight: 1.25;--leading-relaxed: 1.625;--radius-xs: .125rem;--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.\@container\/card-header{container:card-header / inline-size}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-4{top:calc(var(--spacing) * 4)}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-12{bottom:calc(var(--spacing) * 12)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2 / span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-auto{margin-block:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-square{aspect-ratio:1}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-32{height:calc(var(--spacing) * 32)}.h-64{height:calc(var(--spacing) * 64)}.h-\[1\.15rem\]{height:1.15rem}.h-\[120px\]{height:120px}.h-\[200px\]{height:200px}.h-\[280px\]{height:280px}.h-\[300px\]{height:300px}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-\[90vh\]{max-height:90vh}.min-h-\[400px\]{min-height:400px}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-\[100px\]{width:100px}.w-\[120px\]{width:120px}.w-\[140px\]{width:140px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[180px\]{width:180px}.w-\[200px\]{width:200px}.w-\[250px\]{width:250px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-\[200px\]{max-width:200px}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.translate-x-\[-50\%\]{--tw-translate-x: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.-space-x-\[1px\]>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(-1px * var(--tw-space-x-reverse));margin-inline-end:calc(-1px * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style: dashed;border-style:dashed}.border-none{--tw-border-style: none;border-style:none}.border-black{border-color:var(--color-black)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-800{border-color:var(--color-blue-800)}.border-blue-800\/50{border-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.border-blue-800\/50{border-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-800{border-color:var(--color-gray-800)}.border-input{border-color:var(--input)}.border-orange-200{border-color:var(--color-orange-200)}.border-red-600{border-color:var(--color-red-600)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#1e3a8a\]{background-color:#1e3a8a}.bg-\[\#2c7bb6\]{background-color:#2c7bb6}.bg-\[\#4CAF50\]{background-color:#4caf50}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/40{background-color:#0006}@supports (color: color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color: color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-blue-800{background-color:var(--color-blue-800)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:color-mix(in srgb,oklch(.985 .002 247.839) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-50\/50{background-color:color-mix(in oklab,var(--color-gray-50) 50%,transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:color-mix(in srgb,oklch(.928 .006 264.531) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-200\/50{background-color:color-mix(in oklab,var(--color-gray-200) 50%,transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-input-background{background-color:var(--input-background)}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-gradient-to-b{--tw-gradient-position: to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-gray-50{--tw-gradient-from: var(--color-gray-50);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-100{--tw-gradient-to: var(--color-gray-100);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[3px\]{padding:3px}.p-px{padding:1px}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading, var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading, var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading, var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading, var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading: 1;line-height:1}.leading-relaxed{--tw-leading: var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading: var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight: var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight: var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight: var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight: var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking: var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking: var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-\[\#2c7bb6\]{color:#2c7bb6}.text-black{color:var(--color-black)}.text-blue-100{color:var(--color-blue-100)}.text-blue-200{color:var(--color-blue-200)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-muted-foreground{color:var(--muted-foreground)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, )}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, #00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, #0000001a), 0 4px 6px -4px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, #0000001a), 0 8px 10px -6px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-blue-900\/20{--tw-shadow-color: color-mix(in srgb, oklch(.379 .146 265.522) 20%, transparent)}@supports (color: color-mix(in lab,red,red)){.shadow-blue-900\/20{--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-900) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-offset-background{--tw-ring-offset-color: var(--background)}.outline-hidden{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-\[1px\]{--tw-backdrop-blur: blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, )}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration: .2s;transition-duration:.2s}.duration-1000{--tw-duration: 1s;transition-duration:1s}.ease-linear{--tw-ease: linear;transition-timing-function:linear}.outline-none{--tw-outline-style: none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.selection\:bg-primary ::selection,.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection,.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:pb-0:last-child{padding-bottom:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x: 110%;--tw-scale-y: 110%;--tw-scale-z: 110%;scale:var(--tw-scale-x) var(--tw-scale-y)}}@media(hover:hover){.hover\:bg-\[\#43a047\]:hover{background-color:#43a047}}@media(hover:hover){.hover\:bg-\[\#256b9e\]:hover{background-color:#256b9e}}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}}@media(hover:hover){.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}}@media(hover:hover){.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}}@media(hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\:bg-blue-800:hover{background-color:var(--color-blue-800)}}@media(hover:hover){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 30%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in oklab,var(--color-blue-800) 30%,transparent)}}}@media(hover:hover){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}}@media(hover:hover){.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}@media(hover:hover){.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}}@media(hover:hover){.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}}@media(hover:hover){.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){.hover\:bg-red-900\/20:hover{background-color:color-mix(in srgb,oklch(.396 .141 25.723) 20%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-red-900\/20:hover{background-color:color-mix(in oklab,var(--color-red-900) 20%,transparent)}}}@media(hover:hover){.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}}@media(hover:hover){.hover\:bg-yellow-500:hover{background-color:var(--color-yellow-500)}}@media(hover:hover){.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}@media(hover:hover){.hover\:text-gray-600:hover{color:var(--color-gray-600)}}@media(hover:hover){.hover\:text-gray-700:hover{color:var(--color-gray-700)}}@media(hover:hover){.hover\:text-red-600:hover{color:var(--color-red-600)}}@media(hover:hover){.hover\:text-white:hover{color:var(--color-white)}}@media(hover:hover){.hover\:underline:hover{text-decoration-line:underline}}@media(hover:hover){.hover\:opacity-100:hover{opacity:1}}@media(hover:hover){.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:bg-white:focus{background-color:var(--color-white)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\:ring-ring:focus{--tw-ring-color: var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px;--tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: var(--ring)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y: calc(2 * var(--spacing) * -1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x: calc(2 * var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x: calc(2 * var(--spacing) * -1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y: calc(2 * var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:var(--card)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale: .95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale: .95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-switch-background[data-state=unchecked]{background-color:var(--switch-background)}@media(width>=40rem){.sm\:ml-0{margin-left:calc(var(--spacing) * 0)}}@media(width>=40rem){.sm\:w-auto{width:auto}}@media(width>=40rem){.sm\:max-w-\[500px\]{max-width:500px}}@media(width>=40rem){.sm\:max-w-\[600px\]{max-width:600px}}@media(width>=40rem){.sm\:max-w-lg{max-width:var(--container-lg)}}@media(width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=40rem){.sm\:flex-row{flex-direction:row}}@media(width>=40rem){.sm\:items-center{align-items:center}}@media(width>=40rem){.sm\:justify-end{justify-content:flex-end}}@media(width>=40rem){.sm\:text-left{text-align:left}}@media(width>=48rem){.md\:block{display:block}}@media(width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=48rem){.md\:flex-row{flex-direction:row}}@media(width>=48rem){.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}}@media(width>=48rem){.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}}@media(width>=64rem){.lg\:col-span-2{grid-column:span 2 / span 2}}@media(width>=64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:text-muted-foreground:is(.dark *){color:var(--muted-foreground)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}}@media(hover:hover){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:var(--input)}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:var(--foreground)}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.dark\:data-\[state\=unchecked\]\:bg-card-foreground:is(.dark *)[data-state=unchecked]{background-color:var(--card-foreground)}.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:color-mix(in oklab,var(--input) 80%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing) * 0)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}.\[\&\:last-child\]\:pb-6:last-child{padding-bottom:calc(var(--spacing) * 6)}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y: 2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}:root{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size)}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-space-x-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-gradient-position{syntax: "*"; inherits: false}@property --tw-gradient-from{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-via{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-to{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-stops{syntax: "*"; inherits: false}@property --tw-gradient-via-stops{syntax: "*"; inherits: false}@property --tw-gradient-from-position{syntax: "<length-percentage>"; inherits: false; initial-value: 0%;}@property --tw-gradient-via-position{syntax: "<length-percentage>"; inherits: false; initial-value: 50%;}@property --tw-gradient-to-position{syntax: "<length-percentage>"; inherits: false; initial-value: 100%;}@property --tw-leading{syntax: "*"; inherits: false}@property --tw-font-weight{syntax: "*"; inherits: false}@property --tw-tracking{syntax: "*"; inherits: false}@property --tw-ordinal{syntax: "*"; inherits: false}@property --tw-slashed-zero{syntax: "*"; inherits: false}@property --tw-numeric-figure{syntax: "*"; inherits: false}@property --tw-numeric-spacing{syntax: "*"; inherits: false}@property --tw-numeric-fraction{syntax: "*"; inherits: false}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: "<length>"; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-outline-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-backdrop-blur{syntax: "*"; inherits: false}@property --tw-backdrop-brightness{syntax: "*"; inherits: false}@property --tw-backdrop-contrast{syntax: "*"; inherits: false}@property --tw-backdrop-grayscale{syntax: "*"; inherits: false}@property --tw-backdrop-hue-rotate{syntax: "*"; inherits: false}@property --tw-backdrop-invert{syntax: "*"; inherits: false}@property --tw-backdrop-opacity{syntax: "*"; inherits: false}@property --tw-backdrop-saturate{syntax: "*"; inherits: false}@property --tw-backdrop-sepia{syntax: "*"; inherits: false}@property --tw-duration{syntax: "*"; inherits: false}@property --tw-ease{syntax: "*"; inherits: false}@property --tw-scale-x{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-y{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-z{syntax: "*"; inherits: false; initial-value: 1;}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}[data-sonner-toaster]{z-index:10000!important}.category-appearance-toggles button[data-slot=toggle-group-item][data-state=on]{background-color:#2563eb!important;border-color:#2563eb!important;color:#fff!important}.category-appearance-toggles button[data-slot=toggle-group-item][data-state=on]:hover{background-color:#1d4ed8!important;border-color:#1d4ed8!important;color:#fff!important}.category-appearance-toggles button[data-slot=toggle-group-item][data-state=on] svg{color:inherit}[data-slot=dialog-content].form-dialog-shell{display:grid!important;height:70vh!important;max-height:70vh!important;min-height:0!important;width:min(50%,calc(100vw - 2rem))!important;max-width:min(50%,calc(100vw - 2rem))!important;overflow:hidden!important;padding:0!important;box-sizing:border-box!important}[data-slot=dialog-content].form-dialog-shell>.form-dialog-scroll-body{min-height:0!important;max-height:100%!important;overflow-y:auto!important;overflow-x:hidden!important}.form-dialog-scroll-body img{max-width:100%;max-height:120px;object-fit:contain}.form-dialog-scroll-body .group.relative{max-width:100%}@font-face{font-family:FreightSans Bold;src:url(/assets/FreightSans%20Bold-CftzBXfG.ttf) format("truetype");font-weight:700;font-style:normal;font-display:swap}:root{--font-sans: "FreightSans Bold", ui-sans-serif, system-ui, sans-serif;--font-numeric: var(--font-sans)}body{font-family:var(--font-sans)}.font-numeric{font-family:var(--font-sans)!important;font-variant-numeric:tabular-nums} 1 +.rdp{--rdp-cell-size: 40px;--rdp-caption-font-size: 18px;--rdp-accent-color: #0000ff;--rdp-background-color: #e7edff;--rdp-accent-color-dark: #3003e1;--rdp-background-color-dark: #180270;--rdp-outline: 2px solid var(--rdp-accent-color);--rdp-outline-selected: 3px solid var(--rdp-accent-color);--rdp-selected-color: #fff;margin:1em}.rdp-vhidden{box-sizing:border-box;padding:0;margin:0;background:transparent;border:0;-moz-appearance:none;-webkit-appearance:none;appearance:none;position:absolute!important;top:0;width:1px!important;height:1px!important;padding:0!important;overflow:hidden!important;clip:rect(1px,1px,1px,1px)!important;border:0!important}.rdp-button_reset{appearance:none;position:relative;margin:0;padding:0;cursor:default;color:inherit;background:none;font:inherit;-moz-appearance:none;-webkit-appearance:none}.rdp-button_reset:focus-visible{outline:none}.rdp-button{border:2px solid transparent}.rdp-button[disabled]:not(.rdp-day_selected){opacity:.25}.rdp-button:not([disabled]){cursor:pointer}.rdp-button:focus-visible:not([disabled]){color:inherit;background-color:var(--rdp-background-color);border:var(--rdp-outline)}.rdp-button:hover:not([disabled]):not(.rdp-day_selected){background-color:var(--rdp-background-color)}.rdp-months{display:flex}.rdp-month{margin:0 1em}.rdp-month:first-child{margin-left:0}.rdp-month:last-child{margin-right:0}.rdp-table{margin:0;max-width:calc(var(--rdp-cell-size) * 7);border-collapse:collapse}.rdp-with_weeknumber .rdp-table{max-width:calc(var(--rdp-cell-size) * 8);border-collapse:collapse}.rdp-caption{display:flex;align-items:center;justify-content:space-between;padding:0;text-align:left}.rdp-multiple_months .rdp-caption{position:relative;display:block;text-align:center}.rdp-caption_dropdowns{position:relative;display:inline-flex}.rdp-caption_label{position:relative;z-index:1;display:inline-flex;align-items:center;margin:0;padding:0 .25em;white-space:nowrap;color:currentColor;border:0;border:2px solid transparent;font-family:inherit;font-size:var(--rdp-caption-font-size);font-weight:700}.rdp-nav{white-space:nowrap}.rdp-multiple_months .rdp-caption_start .rdp-nav{position:absolute;top:50%;left:0;transform:translateY(-50%)}.rdp-multiple_months .rdp-caption_end .rdp-nav{position:absolute;top:50%;right:0;transform:translateY(-50%)}.rdp-nav_button{display:inline-flex;align-items:center;justify-content:center;width:var(--rdp-cell-size);height:var(--rdp-cell-size);padding:.25em;border-radius:100%}.rdp-dropdown_year,.rdp-dropdown_month{position:relative;display:inline-flex;align-items:center}.rdp-dropdown{appearance:none;position:absolute;z-index:2;top:0;bottom:0;left:0;width:100%;margin:0;padding:0;cursor:inherit;opacity:0;border:none;background-color:transparent;font-family:inherit;font-size:inherit;line-height:inherit}.rdp-dropdown[disabled]{opacity:unset;color:unset}.rdp-dropdown:focus-visible:not([disabled])+.rdp-caption_label{background-color:var(--rdp-background-color);border:var(--rdp-outline);border-radius:6px}.rdp-dropdown_icon{margin:0 0 0 5px}.rdp-head{border:0}.rdp-head_row,.rdp-row{height:100%}.rdp-head_cell{vertical-align:middle;font-size:.75em;font-weight:700;text-align:center;height:100%;height:var(--rdp-cell-size);padding:0;text-transform:uppercase}.rdp-tbody{border:0}.rdp-tfoot{margin:.5em}.rdp-cell{width:var(--rdp-cell-size);height:100%;height:var(--rdp-cell-size);padding:0;text-align:center}.rdp-weeknumber{font-size:.75em}.rdp-weeknumber,.rdp-day{display:flex;overflow:hidden;align-items:center;justify-content:center;box-sizing:border-box;width:var(--rdp-cell-size);max-width:var(--rdp-cell-size);height:var(--rdp-cell-size);margin:0;border:2px solid transparent;border-radius:100%}.rdp-day_today:not(.rdp-day_outside){font-weight:700}.rdp-day_selected,.rdp-day_selected:focus-visible,.rdp-day_selected:hover{color:var(--rdp-selected-color);opacity:1;background-color:var(--rdp-accent-color)}.rdp-day_outside{opacity:.5}.rdp-day_selected:focus-visible{outline:var(--rdp-outline);outline-offset:2px;z-index:1}.rdp:not([dir=rtl]) .rdp-day_range_start:not(.rdp-day_range_end){border-top-right-radius:0;border-bottom-right-radius:0}.rdp:not([dir=rtl]) .rdp-day_range_end:not(.rdp-day_range_start){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_start:not(.rdp-day_range_end){border-top-left-radius:0;border-bottom-left-radius:0}.rdp[dir=rtl] .rdp-day_range_end:not(.rdp-day_range_start){border-top-right-radius:0;border-bottom-right-radius:0}.rdp-day_range_end.rdp-day_range_start{border-radius:100%}.rdp-day_range_middle{border-radius:0}/*! tailwindcss v4.1.3 | MIT License | https://tailwindcss.com */@layer properties{@supports (((-webkit-hyphens: none)) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color: rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-translate-x: 0;--tw-translate-y: 0;--tw-translate-z: 0;--tw-rotate-x: rotateX(0);--tw-rotate-y: rotateY(0);--tw-rotate-z: rotateZ(0);--tw-skew-x: skewX(0);--tw-skew-y: skewY(0);--tw-space-y-reverse: 0;--tw-space-x-reverse: 0;--tw-border-style: solid;--tw-gradient-position: initial;--tw-gradient-from: #0000;--tw-gradient-via: #0000;--tw-gradient-to: #0000;--tw-gradient-stops: initial;--tw-gradient-via-stops: initial;--tw-gradient-from-position: 0%;--tw-gradient-via-position: 50%;--tw-gradient-to-position: 100%;--tw-leading: initial;--tw-font-weight: initial;--tw-tracking: initial;--tw-ordinal: initial;--tw-slashed-zero: initial;--tw-numeric-figure: initial;--tw-numeric-spacing: initial;--tw-numeric-fraction: initial;--tw-shadow: 0 0 #0000;--tw-shadow-color: initial;--tw-shadow-alpha: 100%;--tw-inset-shadow: 0 0 #0000;--tw-inset-shadow-color: initial;--tw-inset-shadow-alpha: 100%;--tw-ring-color: initial;--tw-ring-shadow: 0 0 #0000;--tw-inset-ring-color: initial;--tw-inset-ring-shadow: 0 0 #0000;--tw-ring-inset: initial;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-offset-shadow: 0 0 #0000;--tw-outline-style: solid;--tw-backdrop-blur: initial;--tw-backdrop-brightness: initial;--tw-backdrop-contrast: initial;--tw-backdrop-grayscale: initial;--tw-backdrop-hue-rotate: initial;--tw-backdrop-invert: initial;--tw-backdrop-opacity: initial;--tw-backdrop-saturate: initial;--tw-backdrop-sepia: initial;--tw-duration: initial;--tw-ease: initial;--tw-scale-x: 1;--tw-scale-y: 1;--tw-scale-z: 1}}}@layer theme{:root,:host{--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-100: oklch(.936 .032 17.717);--color-red-300: oklch(.808 .114 19.571);--color-red-400: oklch(.704 .191 22.216);--color-red-500: oklch(.637 .237 25.331);--color-red-600: oklch(.577 .245 27.325);--color-red-700: oklch(.505 .213 27.518);--color-red-900: oklch(.396 .141 25.723);--color-orange-50: oklch(.98 .016 73.684);--color-orange-200: oklch(.901 .076 70.697);--color-orange-500: oklch(.705 .213 47.604);--color-orange-700: oklch(.553 .195 38.402);--color-yellow-400: oklch(.852 .199 91.936);--color-yellow-500: oklch(.795 .184 86.047);--color-green-100: oklch(.962 .044 156.743);--color-green-500: oklch(.723 .219 149.579);--color-green-600: oklch(.627 .194 149.214);--color-green-700: oklch(.527 .154 150.069);--color-emerald-50: oklch(.979 .021 166.113);--color-emerald-600: oklch(.596 .145 163.225);--color-blue-50: oklch(.97 .014 254.604);--color-blue-100: oklch(.932 .032 255.585);--color-blue-200: oklch(.882 .059 254.128);--color-blue-300: oklch(.809 .105 251.813);--color-blue-400: oklch(.707 .165 254.624);--color-blue-500: oklch(.623 .214 259.815);--color-blue-600: oklch(.546 .245 262.881);--color-blue-700: oklch(.488 .243 264.376);--color-blue-800: oklch(.424 .199 265.638);--color-blue-900: oklch(.379 .146 265.522);--color-indigo-50: oklch(.962 .018 272.314);--color-indigo-600: oklch(.511 .262 276.966);--color-gray-50: oklch(.985 .002 247.839);--color-gray-100: oklch(.967 .003 264.542);--color-gray-200: oklch(.928 .006 264.531);--color-gray-300: oklch(.872 .01 258.338);--color-gray-400: oklch(.707 .022 261.325);--color-gray-500: oklch(.551 .027 264.364);--color-gray-600: oklch(.446 .03 256.802);--color-gray-700: oklch(.373 .034 259.733);--color-gray-800: oklch(.278 .033 256.848);--color-gray-900: oklch(.21 .034 264.665);--color-black: #000;--color-white: #fff;--spacing: .25rem;--container-xs: 20rem;--container-md: 28rem;--container-lg: 32rem;--text-xs: .75rem;--text-xs--line-height: calc(1 / .75);--text-sm: .875rem;--text-sm--line-height: calc(1.25 / .875);--text-base: 1rem;--text-base--line-height: 1.5 ;--text-lg: 1.125rem;--text-lg--line-height: calc(1.75 / 1.125);--text-xl: 1.25rem;--text-xl--line-height: calc(1.75 / 1.25);--text-2xl: 1.5rem;--text-2xl--line-height: calc(2 / 1.5);--text-3xl: 1.875rem;--text-3xl--line-height: 1.2 ;--font-weight-light: 300;--font-weight-normal: 400;--font-weight-medium: 500;--font-weight-semibold: 600;--font-weight-bold: 700;--tracking-wide: .025em;--tracking-wider: .05em;--leading-tight: 1.25;--leading-relaxed: 1.625;--radius-xs: .125rem;--animate-pulse: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--default-transition-duration: .15s;--default-transition-timing-function: cubic-bezier(.4, 0, .2, 1);--default-font-family: var(--font-sans);--default-font-feature-settings: var(--font-sans--font-feature-settings);--default-font-variation-settings: var(--font-sans--font-variation-settings);--default-mono-font-family: var(--font-mono);--default-mono-font-feature-settings: var(--font-mono--font-feature-settings);--default-mono-font-variation-settings: var(--font-mono--font-variation-settings)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings, normal);font-variation-settings:var(--default-font-variation-settings, normal);-webkit-tap-highlight-color:transparent}body{line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings, normal);font-variation-settings:var(--default-mono-font-variation-settings, normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;--lightningcss-light: initial;--lightningcss-dark: ;color-scheme:light;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1;color:currentColor}@supports (color: color-mix(in lab,red,red)){::placeholder{color:color-mix(in oklab,currentColor 50%,transparent)}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}body{background-color:var(--background);color:var(--foreground)}*{border-color:var(--border);outline-color:var(--ring)}@supports (color: color-mix(in lab,red,red)){*{outline-color:color-mix(in oklab,var(--ring) 50%,transparent)}}body{background-color:var(--background);color:var(--foreground);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h1{font-size:var(--text-2xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h2{font-size:var(--text-xl);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h3{font-size:var(--text-lg);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) h4,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) label,:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) button{font-size:var(--text-base);font-weight:var(--font-weight-medium);line-height:1.5}:where(:not(:has([class*=" text-"]),:not(:has([class^=text-])))) input{font-size:var(--text-base);font-weight:var(--font-weight-normal);line-height:1.5}}@layer utilities{.\@container\/card-header{container:card-header / inline-size}.pointer-events-none{pointer-events:none}.visible{visibility:visible}.sr-only{clip:rect(0,0,0,0);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.sticky{position:sticky}.inset-0{inset:calc(var(--spacing) * 0)}.top-0{top:calc(var(--spacing) * 0)}.top-2\.5{top:calc(var(--spacing) * 2.5)}.top-4{top:calc(var(--spacing) * 4)}.top-\[1px\]{top:1px}.top-\[50\%\]{top:50%}.right-2{right:calc(var(--spacing) * 2)}.right-4{right:calc(var(--spacing) * 4)}.bottom-12{bottom:calc(var(--spacing) * 12)}.left-2\.5{left:calc(var(--spacing) * 2.5)}.left-\[50\%\]{left:50%}.z-10{z-index:10}.z-20{z-index:20}.z-50{z-index:50}.col-span-1{grid-column:span 1 / span 1}.col-span-2{grid-column:span 2 / span 2}.col-start-2{grid-column-start:2}.row-span-2{grid-row:span 2 / span 2}.row-start-1{grid-row-start:1}.-mx-1{margin-inline:calc(var(--spacing) * -1)}.mx-2{margin-inline:calc(var(--spacing) * 2)}.my-1{margin-block:calc(var(--spacing) * 1)}.my-auto{margin-block:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-8{margin-top:calc(var(--spacing) * 8)}.mr-1{margin-right:calc(var(--spacing) * 1)}.mr-2{margin-right:calc(var(--spacing) * 2)}.mr-3{margin-right:calc(var(--spacing) * 3)}.mr-4{margin-right:calc(var(--spacing) * 4)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-2{margin-left:calc(var(--spacing) * 2)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline-block{display:inline-block}.inline-flex{display:inline-flex}.table{display:table}.table-caption{display:table-caption}.table-cell{display:table-cell}.table-row{display:table-row}.aspect-square{aspect-ratio:1}.size-3\.5{width:calc(var(--spacing) * 3.5);height:calc(var(--spacing) * 3.5)}.size-4{width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.size-9{width:calc(var(--spacing) * 9);height:calc(var(--spacing) * 9)}.size-10{width:calc(var(--spacing) * 10);height:calc(var(--spacing) * 10)}.size-full{width:100%;height:100%}.h-1{height:calc(var(--spacing) * 1)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-12{height:calc(var(--spacing) * 12)}.h-14{height:calc(var(--spacing) * 14)}.h-32{height:calc(var(--spacing) * 32)}.h-64{height:calc(var(--spacing) * 64)}.h-\[1\.15rem\]{height:1.15rem}.h-\[120px\]{height:120px}.h-\[200px\]{height:200px}.h-\[280px\]{height:280px}.h-\[300px\]{height:300px}.h-\[calc\(100\%-1px\)\]{height:calc(100% - 1px)}.h-\[var\(--radix-select-trigger-height\)\]{height:var(--radix-select-trigger-height)}.h-auto{height:auto}.h-full{height:100%}.h-px{height:1px}.h-screen{height:100vh}.max-h-\(--radix-select-content-available-height\){max-height:var(--radix-select-content-available-height)}.max-h-\[90vh\]{max-height:90vh}.min-h-\[400px\]{min-height:400px}.w-1{width:calc(var(--spacing) * 1)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-8{width:calc(var(--spacing) * 8)}.w-10{width:calc(var(--spacing) * 10)}.w-32{width:calc(var(--spacing) * 32)}.w-64{width:calc(var(--spacing) * 64)}.w-\[100px\]{width:100px}.w-\[120px\]{width:120px}.w-\[140px\]{width:140px}.w-\[150px\]{width:150px}.w-\[160px\]{width:160px}.w-\[180px\]{width:180px}.w-\[200px\]{width:200px}.w-\[250px\]{width:250px}.w-\[600px\]{width:600px}.w-auto{width:auto}.w-fit{width:fit-content}.w-full{width:100%}.w-px{width:1px}.max-w-\[200px\]{max-width:200px}.max-w-\[calc\(100\%-2rem\)\]{max-width:calc(100% - 2rem)}.max-w-full{max-width:100%}.max-w-md{max-width:var(--container-md)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[8rem\]{min-width:8rem}.min-w-\[var\(--radix-select-trigger-width\)\]{min-width:var(--radix-select-trigger-width)}.flex-1{flex:1}.flex-shrink-0,.shrink-0{flex-shrink:0}.caption-bottom{caption-side:bottom}.origin-\(--radix-select-content-transform-origin\){transform-origin:var(--radix-select-content-transform-origin)}.translate-x-\[-50\%\]{--tw-translate-x: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.translate-y-\[-50\%\]{--tw-translate-y: -50%;translate:var(--tw-translate-x) var(--tw-translate-y)}.-rotate-90{rotate:-90deg}.transform{transform:var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y)}.animate-pulse{animation:var(--animate-pulse)}.cursor-default{cursor:default}.cursor-pointer{cursor:pointer}.touch-none{touch-action:none}.scroll-my-1{scroll-margin-block:calc(var(--spacing) * 1)}.auto-rows-min{grid-auto-rows:min-content}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}.grid-rows-\[auto_auto\]{grid-template-rows:auto auto}.flex-col{flex-direction:column}.flex-col-reverse{flex-direction:column-reverse}.flex-row{flex-direction:row}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0{gap:calc(var(--spacing) * 0)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}.gap-6{gap:calc(var(--spacing) * 6)}:where(.space-y-0>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 0) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 0) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-2>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 2) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse: 0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}:where(.-space-x-\[1px\]>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(-1px * var(--tw-space-x-reverse));margin-inline-end:calc(-1px * calc(1 - var(--tw-space-x-reverse)))}:where(.space-x-2>:not(:last-child)){--tw-space-x-reverse: 0;margin-inline-start:calc(calc(var(--spacing) * 2) * var(--tw-space-x-reverse));margin-inline-end:calc(calc(var(--spacing) * 2) * calc(1 - var(--tw-space-x-reverse)))}.self-start{align-self:flex-start}.justify-self-end{justify-self:flex-end}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-auto{overflow:auto}.overflow-hidden{overflow:hidden}.overflow-x-auto{overflow-x:auto}.overflow-x-hidden{overflow-x:hidden}.overflow-y-auto{overflow-y:auto}.rounded{border-radius:.25rem}.rounded-\[4px\]{border-radius:4px}.rounded-\[inherit\]{border-radius:inherit}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius)}.rounded-md{border-radius:calc(var(--radius) - 2px)}.rounded-none{border-radius:0}.rounded-sm{border-radius:calc(var(--radius) - 4px)}.rounded-xl{border-radius:calc(var(--radius) + 4px)}.rounded-xs{border-radius:var(--radius-xs)}.border{border-style:var(--tw-border-style);border-width:1px}.border-2{border-style:var(--tw-border-style);border-width:2px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-r{border-right-style:var(--tw-border-style);border-right-width:1px}.border-r-0{border-right-style:var(--tw-border-style);border-right-width:0}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-b-2{border-bottom-style:var(--tw-border-style);border-bottom-width:2px}.border-l{border-left-style:var(--tw-border-style);border-left-width:1px}.border-l-2{border-left-style:var(--tw-border-style);border-left-width:2px}.border-dashed{--tw-border-style: dashed;border-style:dashed}.border-none{--tw-border-style: none;border-style:none}.border-black{border-color:var(--color-black)}.border-blue-200{border-color:var(--color-blue-200)}.border-blue-400{border-color:var(--color-blue-400)}.border-blue-800{border-color:var(--color-blue-800)}.border-blue-800\/50{border-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.border-blue-800\/50{border-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}.border-gray-100{border-color:var(--color-gray-100)}.border-gray-200{border-color:var(--color-gray-200)}.border-gray-300{border-color:var(--color-gray-300)}.border-gray-400{border-color:var(--color-gray-400)}.border-gray-800{border-color:var(--color-gray-800)}.border-input{border-color:var(--input)}.border-orange-200{border-color:var(--color-orange-200)}.border-red-600{border-color:var(--color-red-600)}.border-transparent{border-color:#0000}.border-t-transparent{border-top-color:#0000}.border-l-transparent{border-left-color:#0000}.bg-\[\#1e3a8a\]{background-color:#1e3a8a}.bg-\[\#2c7bb6\]{background-color:#2c7bb6}.bg-\[\#4CAF50\]{background-color:#4caf50}.bg-background{background-color:var(--background)}.bg-black{background-color:var(--color-black)}.bg-black\/40{background-color:#0006}@supports (color: color-mix(in lab,red,red)){.bg-black\/40{background-color:color-mix(in oklab,var(--color-black) 40%,transparent)}}.bg-black\/50{background-color:#00000080}@supports (color: color-mix(in lab,red,red)){.bg-black\/50{background-color:color-mix(in oklab,var(--color-black) 50%,transparent)}}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-100{background-color:var(--color-blue-100)}.bg-blue-600{background-color:var(--color-blue-600)}.bg-blue-700{background-color:var(--color-blue-700)}.bg-blue-800{background-color:var(--color-blue-800)}.bg-border{background-color:var(--border)}.bg-card{background-color:var(--card)}.bg-current{background-color:currentColor}.bg-destructive{background-color:var(--destructive)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-gray-50{background-color:var(--color-gray-50)}.bg-gray-50\/50{background-color:color-mix(in srgb,oklch(.985 .002 247.839) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-50\/50{background-color:color-mix(in oklab,var(--color-gray-50) 50%,transparent)}}.bg-gray-100{background-color:var(--color-gray-100)}.bg-gray-200{background-color:var(--color-gray-200)}.bg-gray-200\/50{background-color:color-mix(in srgb,oklch(.928 .006 264.531) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.bg-gray-200\/50{background-color:color-mix(in oklab,var(--color-gray-200) 50%,transparent)}}.bg-gray-400{background-color:var(--color-gray-400)}.bg-gray-700{background-color:var(--color-gray-700)}.bg-gray-900{background-color:var(--color-gray-900)}.bg-green-100{background-color:var(--color-green-100)}.bg-green-500{background-color:var(--color-green-500)}.bg-green-600{background-color:var(--color-green-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-input-background{background-color:var(--input-background)}.bg-muted,.bg-muted\/50{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.bg-muted\/50{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}.bg-orange-50{background-color:var(--color-orange-50)}.bg-popover{background-color:var(--popover)}.bg-primary{background-color:var(--primary)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-secondary{background-color:var(--secondary)}.bg-transparent{background-color:#0000}.bg-white{background-color:var(--color-white)}.bg-yellow-400{background-color:var(--color-yellow-400)}.bg-yellow-500{background-color:var(--color-yellow-500)}.bg-gradient-to-b{--tw-gradient-position: to bottom in oklab;background-image:linear-gradient(var(--tw-gradient-stops))}.from-gray-50{--tw-gradient-from: var(--color-gray-50);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.to-gray-100{--tw-gradient-to: var(--color-gray-100);--tw-gradient-stops: var(--tw-gradient-via-stops, var(--tw-gradient-position), var(--tw-gradient-from) var(--tw-gradient-from-position), var(--tw-gradient-to) var(--tw-gradient-to-position))}.fill-current{fill:currentColor}.object-contain{object-fit:contain}.p-1{padding:calc(var(--spacing) * 1)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.p-8{padding:calc(var(--spacing) * 8)}.p-\[3px\]{padding:3px}.p-px{padding:1px}.px-0{padding-inline:calc(var(--spacing) * 0)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-6{padding-inline:calc(var(--spacing) * 6)}.px-8{padding-inline:calc(var(--spacing) * 8)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-4{padding-block:calc(var(--spacing) * 4)}.pt-2{padding-top:calc(var(--spacing) * 2)}.pt-6{padding-top:calc(var(--spacing) * 6)}.pr-8{padding-right:calc(var(--spacing) * 8)}.pb-0{padding-bottom:calc(var(--spacing) * 0)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.pb-3{padding-bottom:calc(var(--spacing) * 3)}.pb-6{padding-bottom:calc(var(--spacing) * 6)}.pl-2{padding-left:calc(var(--spacing) * 2)}.pl-4{padding-left:calc(var(--spacing) * 4)}.pl-6{padding-left:calc(var(--spacing) * 6)}.pl-9{padding-left:calc(var(--spacing) * 9)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.align-middle{vertical-align:middle}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading, var(--text-2xl--line-height))}.text-3xl{font-size:var(--text-3xl);line-height:var(--tw-leading, var(--text-3xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading, var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading, var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading, var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-none{--tw-leading: 1;line-height:1}.leading-relaxed{--tw-leading: var(--leading-relaxed);line-height:var(--leading-relaxed)}.leading-tight{--tw-leading: var(--leading-tight);line-height:var(--leading-tight)}.font-bold{--tw-font-weight: var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-light{--tw-font-weight: var(--font-weight-light);font-weight:var(--font-weight-light)}.font-medium{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight: var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight: var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-wide{--tw-tracking: var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-wider{--tw-tracking: var(--tracking-wider);letter-spacing:var(--tracking-wider)}.whitespace-nowrap{white-space:nowrap}.text-\[\#2c7bb6\]{color:#2c7bb6}.text-black{color:var(--color-black)}.text-blue-100{color:var(--color-blue-100)}.text-blue-200{color:var(--color-blue-200)}.text-blue-300{color:var(--color-blue-300)}.text-blue-400{color:var(--color-blue-400)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-blue-800{color:var(--color-blue-800)}.text-blue-900{color:var(--color-blue-900)}.text-card-foreground{color:var(--card-foreground)}.text-current{color:currentColor}.text-emerald-600{color:var(--color-emerald-600)}.text-foreground{color:var(--foreground)}.text-gray-300{color:var(--color-gray-300)}.text-gray-400{color:var(--color-gray-400)}.text-gray-500{color:var(--color-gray-500)}.text-gray-600{color:var(--color-gray-600)}.text-gray-700{color:var(--color-gray-700)}.text-gray-800{color:var(--color-gray-800)}.text-gray-900{color:var(--color-gray-900)}.text-green-500{color:var(--color-green-500)}.text-green-600{color:var(--color-green-600)}.text-green-700{color:var(--color-green-700)}.text-indigo-600{color:var(--color-indigo-600)}.text-muted-foreground{color:var(--muted-foreground)}.text-orange-500{color:var(--color-orange-500)}.text-orange-700{color:var(--color-orange-700)}.text-popover-foreground{color:var(--popover-foreground)}.text-primary{color:var(--primary)}.text-primary-foreground{color:var(--primary-foreground)}.text-red-300{color:var(--color-red-300)}.text-red-400{color:var(--color-red-400)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-secondary-foreground{color:var(--secondary-foreground)}.text-white{color:var(--color-white)}.capitalize{text-transform:capitalize}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing: tabular-nums;font-variant-numeric:var(--tw-ordinal, ) var(--tw-slashed-zero, ) var(--tw-numeric-figure, ) var(--tw-numeric-spacing, ) var(--tw-numeric-fraction, )}.underline-offset-4{text-underline-offset:4px}.opacity-50{opacity:.5}.opacity-70{opacity:.7}.shadow-2xl{--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, #00000040);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-lg{--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, #0000001a), 0 4px 6px -4px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-md{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-sm{--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, #0000001a), 0 1px 2px -1px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xl{--tw-shadow: 0 20px 25px -5px var(--tw-shadow-color, #0000001a), 0 8px 10px -6px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-xs{--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, #0000000d);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.ring-0{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.shadow-blue-900\/20{--tw-shadow-color: color-mix(in srgb, oklch(.379 .146 265.522) 20%, transparent)}@supports (color: color-mix(in lab,red,red)){.shadow-blue-900\/20{--tw-shadow-color: color-mix(in oklab, color-mix(in oklab, var(--color-blue-900) 20%, transparent) var(--tw-shadow-alpha), transparent)}}.ring-offset-background{--tw-ring-offset-color: var(--background)}.outline-hidden{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.outline-hidden{outline-offset:2px;outline:2px solid #0000}}.outline{outline-style:var(--tw-outline-style);outline-width:1px}.backdrop-blur-\[1px\]{--tw-backdrop-blur: blur(1px);-webkit-backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, );backdrop-filter:var(--tw-backdrop-blur, ) var(--tw-backdrop-brightness, ) var(--tw-backdrop-contrast, ) var(--tw-backdrop-grayscale, ) var(--tw-backdrop-hue-rotate, ) var(--tw-backdrop-invert, ) var(--tw-backdrop-opacity, ) var(--tw-backdrop-saturate, ) var(--tw-backdrop-sepia, )}.transition-\[color\,box-shadow\]{transition-property:color,box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-opacity{transition-property:opacity;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-transform{transition-property:transform,translate,scale,rotate;transition-timing-function:var(--tw-ease, var(--default-transition-timing-function));transition-duration:var(--tw-duration, var(--default-transition-duration))}.transition-none{transition-property:none}.duration-200{--tw-duration: .2s;transition-duration:.2s}.duration-1000{--tw-duration: 1s;transition-duration:1s}.ease-linear{--tw-ease: linear;transition-timing-function:linear}.outline-none{--tw-outline-style: none;outline-style:none}.select-none{-webkit-user-select:none;user-select:none}.running{animation-play-state:running}.group-data-\[disabled\=true\]\:pointer-events-none:is(:where(.group)[data-disabled=true] *){pointer-events:none}.group-data-\[disabled\=true\]\:opacity-50:is(:where(.group)[data-disabled=true] *){opacity:.5}.peer-disabled\:cursor-not-allowed:is(:where(.peer):disabled~*){cursor:not-allowed}.peer-disabled\:opacity-50:is(:where(.peer):disabled~*){opacity:.5}.peer-disabled\:opacity-70:is(:where(.peer):disabled~*){opacity:.7}.selection\:bg-primary ::selection,.selection\:bg-primary::selection{background-color:var(--primary)}.selection\:text-primary-foreground ::selection,.selection\:text-primary-foreground::selection{color:var(--primary-foreground)}.file\:inline-flex::file-selector-button{display:inline-flex}.file\:h-7::file-selector-button{height:calc(var(--spacing) * 7)}.file\:border-0::file-selector-button{border-style:var(--tw-border-style);border-width:0}.file\:bg-transparent::file-selector-button{background-color:#0000}.file\:text-sm::file-selector-button{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}.file\:font-medium::file-selector-button{--tw-font-weight: var(--font-weight-medium);font-weight:var(--font-weight-medium)}.file\:text-foreground::file-selector-button{color:var(--foreground)}.placeholder\:text-muted-foreground::placeholder{color:var(--muted-foreground)}.last\:border-0:last-child{border-style:var(--tw-border-style);border-width:0}.last\:pb-0:last-child{padding-bottom:calc(var(--spacing) * 0)}@media(hover:hover){.hover\:scale-110:hover{--tw-scale-x: 110%;--tw-scale-y: 110%;--tw-scale-z: 110%;scale:var(--tw-scale-x) var(--tw-scale-y)}}@media(hover:hover){.hover\:bg-\[\#43a047\]:hover{background-color:#43a047}}@media(hover:hover){.hover\:bg-\[\#256b9e\]:hover{background-color:#256b9e}}@media(hover:hover){.hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){.hover\:bg-blue-50:hover{background-color:var(--color-blue-50)}}@media(hover:hover){.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}}@media(hover:hover){.hover\:bg-blue-500:hover{background-color:var(--color-blue-500)}}@media(hover:hover){.hover\:bg-blue-600:hover{background-color:var(--color-blue-600)}}@media(hover:hover){.hover\:bg-blue-700:hover{background-color:var(--color-blue-700)}}@media(hover:hover){.hover\:bg-blue-800:hover{background-color:var(--color-blue-800)}}@media(hover:hover){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 30%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/30:hover{background-color:color-mix(in oklab,var(--color-blue-800) 30%,transparent)}}}@media(hover:hover){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in srgb,oklch(.424 .199 265.638) 50%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-blue-800\/50:hover{background-color:color-mix(in oklab,var(--color-blue-800) 50%,transparent)}}}@media(hover:hover){.hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){.hover\:bg-gray-50:hover{background-color:var(--color-gray-50)}}@media(hover:hover){.hover\:bg-gray-100:hover{background-color:var(--color-gray-100)}}@media(hover:hover){.hover\:bg-gray-200:hover{background-color:var(--color-gray-200)}}@media(hover:hover){.hover\:bg-muted\/50:hover{background-color:var(--muted)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-muted\/50:hover{background-color:color-mix(in oklab,var(--muted) 50%,transparent)}}}@media(hover:hover){.hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){.hover\:bg-red-900\/20:hover{background-color:color-mix(in srgb,oklch(.396 .141 25.723) 20%,transparent)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-red-900\/20:hover{background-color:color-mix(in oklab,var(--color-red-900) 20%,transparent)}}}@media(hover:hover){.hover\:bg-secondary\/80:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){.hover\:bg-secondary\/80:hover{background-color:color-mix(in oklab,var(--secondary) 80%,transparent)}}}@media(hover:hover){.hover\:bg-yellow-500:hover{background-color:var(--color-yellow-500)}}@media(hover:hover){.hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}@media(hover:hover){.hover\:text-gray-600:hover{color:var(--color-gray-600)}}@media(hover:hover){.hover\:text-gray-700:hover{color:var(--color-gray-700)}}@media(hover:hover){.hover\:text-red-600:hover{color:var(--color-red-600)}}@media(hover:hover){.hover\:text-white:hover{color:var(--color-white)}}@media(hover:hover){.hover\:underline:hover{text-decoration-line:underline}}@media(hover:hover){.hover\:opacity-100:hover{opacity:1}}@media(hover:hover){.hover\:shadow-md:hover{--tw-shadow: 0 4px 6px -1px var(--tw-shadow-color, #0000001a), 0 2px 4px -2px var(--tw-shadow-color, #0000001a);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}}.focus\:bg-accent:focus{background-color:var(--accent)}.focus\:bg-white:focus{background-color:var(--color-white)}.focus\:text-accent-foreground:focus{color:var(--accent-foreground)}.focus\:ring-2:focus{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus\:ring-blue-500:focus{--tw-ring-color: var(--color-blue-500)}.focus\:ring-ring:focus{--tw-ring-color: var(--ring)}.focus\:ring-offset-2:focus{--tw-ring-offset-width: 2px;--tw-ring-offset-shadow: var(--tw-ring-inset, ) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-hidden:focus{--tw-outline-style: none;outline-style:none}@media(forced-colors:active){.focus\:outline-hidden:focus{outline-offset:2px;outline:2px solid #0000}}.focus-visible\:border-ring:focus-visible{border-color:var(--ring)}.focus-visible\:ring-0:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-\[3px\]:focus-visible{--tw-ring-shadow: var(--tw-ring-inset, ) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);box-shadow:var(--tw-inset-shadow),var(--tw-inset-ring-shadow),var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow)}.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-destructive\/20:focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: var(--ring)}@supports (color: color-mix(in lab,red,red)){.focus-visible\:ring-ring\/50:focus-visible{--tw-ring-color: color-mix(in oklab, var(--ring) 50%, transparent)}}.focus-visible\:outline-1:focus-visible{outline-style:var(--tw-outline-style);outline-width:1px}.focus-visible\:outline-ring:focus-visible{outline-color:var(--ring)}.disabled\:pointer-events-none:disabled{pointer-events:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:opacity-50:disabled{opacity:.5}.has-data-\[slot\=card-action\]\:grid-cols-\[1fr_auto\]:has([data-slot=card-action]){grid-template-columns:1fr auto}.has-\[\>svg\]\:px-2\.5:has(>svg){padding-inline:calc(var(--spacing) * 2.5)}.has-\[\>svg\]\:px-3:has(>svg){padding-inline:calc(var(--spacing) * 3)}.has-\[\>svg\]\:px-4:has(>svg){padding-inline:calc(var(--spacing) * 4)}.aria-invalid\:border-destructive[aria-invalid=true]{border-color:var(--destructive)}.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.aria-invalid\:ring-destructive\/20[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 20%, transparent)}}.data-\[disabled\]\:pointer-events-none[data-disabled]{pointer-events:none}.data-\[disabled\]\:opacity-50[data-disabled]{opacity:.5}.data-\[placeholder\]\:text-muted-foreground[data-placeholder]{color:var(--muted-foreground)}.data-\[side\=bottom\]\:translate-y-1[data-side=bottom]{--tw-translate-y: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=bottom\]\:slide-in-from-top-2[data-side=bottom]{--tw-enter-translate-y: calc(2 * var(--spacing) * -1)}.data-\[side\=left\]\:-translate-x-1[data-side=left]{--tw-translate-x: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=left\]\:slide-in-from-right-2[data-side=left]{--tw-enter-translate-x: calc(2 * var(--spacing))}.data-\[side\=right\]\:translate-x-1[data-side=right]{--tw-translate-x: calc(var(--spacing) * 1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=right\]\:slide-in-from-left-2[data-side=right]{--tw-enter-translate-x: calc(2 * var(--spacing) * -1)}.data-\[side\=top\]\:-translate-y-1[data-side=top]{--tw-translate-y: calc(var(--spacing) * -1);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[side\=top\]\:slide-in-from-bottom-2[data-side=top]{--tw-enter-translate-y: calc(2 * var(--spacing))}.data-\[size\=default\]\:h-9[data-size=default]{height:calc(var(--spacing) * 9)}.data-\[size\=sm\]\:h-8[data-size=sm]{height:calc(var(--spacing) * 8)}:is(.\*\:data-\[slot\=select-value\]\:line-clamp-1>*)[data-slot=select-value]{-webkit-line-clamp:1;-webkit-box-orient:vertical;display:-webkit-box;overflow:hidden}:is(.\*\:data-\[slot\=select-value\]\:flex>*)[data-slot=select-value]{display:flex}:is(.\*\:data-\[slot\=select-value\]\:items-center>*)[data-slot=select-value]{align-items:center}:is(.\*\:data-\[slot\=select-value\]\:gap-2>*)[data-slot=select-value]{gap:calc(var(--spacing) * 2)}.data-\[state\=active\]\:bg-card[data-state=active]{background-color:var(--card)}.data-\[state\=checked\]\:translate-x-\[calc\(100\%-2px\)\][data-state=checked]{--tw-translate-x: calc(100% - 2px) ;translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=checked\]\:border-primary[data-state=checked]{border-color:var(--primary)}.data-\[state\=checked\]\:bg-primary[data-state=checked]{background-color:var(--primary)}.data-\[state\=checked\]\:text-primary-foreground[data-state=checked]{color:var(--primary-foreground)}.data-\[state\=closed\]\:animate-out[data-state=closed]{animation:exit var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=closed\]\:fade-out-0[data-state=closed]{--tw-exit-opacity: 0}.data-\[state\=closed\]\:zoom-out-95[data-state=closed]{--tw-exit-scale: .95}.data-\[state\=open\]\:animate-in[data-state=open]{animation:enter var(--tw-duration, .15s) var(--tw-ease, ease)}.data-\[state\=open\]\:bg-accent[data-state=open]{background-color:var(--accent)}.data-\[state\=open\]\:text-muted-foreground[data-state=open]{color:var(--muted-foreground)}.data-\[state\=open\]\:fade-in-0[data-state=open]{--tw-enter-opacity: 0}.data-\[state\=open\]\:zoom-in-95[data-state=open]{--tw-enter-scale: .95}.data-\[state\=selected\]\:bg-muted[data-state=selected]{background-color:var(--muted)}.data-\[state\=unchecked\]\:translate-x-0[data-state=unchecked]{--tw-translate-x: calc(var(--spacing) * 0);translate:var(--tw-translate-x) var(--tw-translate-y)}.data-\[state\=unchecked\]\:bg-switch-background[data-state=unchecked]{background-color:var(--switch-background)}@media(width>=40rem){.sm\:ml-0{margin-left:calc(var(--spacing) * 0)}}@media(width>=40rem){.sm\:w-auto{width:auto}}@media(width>=40rem){.sm\:max-w-\[500px\]{max-width:500px}}@media(width>=40rem){.sm\:max-w-\[600px\]{max-width:600px}}@media(width>=40rem){.sm\:max-w-lg{max-width:var(--container-lg)}}@media(width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=40rem){.sm\:flex-row{flex-direction:row}}@media(width>=40rem){.sm\:items-center{align-items:center}}@media(width>=40rem){.sm\:justify-end{justify-content:flex-end}}@media(width>=40rem){.sm\:text-left{text-align:left}}@media(width>=48rem){.md\:block{display:block}}@media(width>=48rem){.md\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=48rem){.md\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=48rem){.md\:flex-row{flex-direction:row}}@media(width>=48rem){.md\:text-base{font-size:var(--text-base);line-height:var(--tw-leading, var(--text-base--line-height))}}@media(width>=48rem){.md\:text-sm{font-size:var(--text-sm);line-height:var(--tw-leading, var(--text-sm--line-height))}}@media(width>=64rem){.lg\:col-span-2{grid-column:span 2 / span 2}}@media(width>=64rem){.lg\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media(width>=64rem){.lg\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}@media(width>=80rem){.xl\:grid-cols-4{grid-template-columns:repeat(4,minmax(0,1fr))}}.dark\:border-input:is(.dark *){border-color:var(--input)}.dark\:bg-destructive\/60:is(.dark *){background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-destructive\/60:is(.dark *){background-color:color-mix(in oklab,var(--destructive) 60%,transparent)}}.dark\:bg-input\/30:is(.dark *){background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:bg-input\/30:is(.dark *){background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:text-muted-foreground:is(.dark *){color:var(--muted-foreground)}@media(hover:hover){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:var(--accent)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-accent\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--accent) 50%,transparent)}}}@media(hover:hover){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:hover\:bg-input\/50:is(.dark *):hover{background-color:color-mix(in oklab,var(--input) 50%,transparent)}}}.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:focus-visible\:ring-destructive\/40:is(.dark *):focus-visible{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: var(--destructive)}@supports (color: color-mix(in lab,red,red)){.dark\:aria-invalid\:ring-destructive\/40:is(.dark *)[aria-invalid=true]{--tw-ring-color: color-mix(in oklab, var(--destructive) 40%, transparent)}}.dark\:data-\[state\=active\]\:border-input:is(.dark *)[data-state=active]{border-color:var(--input)}.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=active\]\:bg-input\/30:is(.dark *)[data-state=active]{background-color:color-mix(in oklab,var(--input) 30%,transparent)}}.dark\:data-\[state\=active\]\:text-foreground:is(.dark *)[data-state=active]{color:var(--foreground)}.dark\:data-\[state\=checked\]\:bg-primary:is(.dark *)[data-state=checked]{background-color:var(--primary)}.dark\:data-\[state\=checked\]\:bg-primary-foreground:is(.dark *)[data-state=checked]{background-color:var(--primary-foreground)}.dark\:data-\[state\=unchecked\]\:bg-card-foreground:is(.dark *)[data-state=unchecked]{background-color:var(--card-foreground)}.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:var(--input)}@supports (color: color-mix(in lab,red,red)){.dark\:data-\[state\=unchecked\]\:bg-input\/80:is(.dark *)[data-state=unchecked]{background-color:color-mix(in oklab,var(--input) 80%,transparent)}}.\[\&_svg\]\:pointer-events-none svg{pointer-events:none}.\[\&_svg\]\:shrink-0 svg{flex-shrink:0}.\[\&_svg\:not\(\[class\*\=\'size-\'\]\)\]\:size-4 svg:not([class*=size-]){width:calc(var(--spacing) * 4);height:calc(var(--spacing) * 4)}.\[\&_svg\:not\(\[class\*\=\'text-\'\]\)\]\:text-muted-foreground svg:not([class*=text-]){color:var(--muted-foreground)}.\[\&_tr\]\:border-b tr{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.\[\&_tr\:last-child\]\:border-0 tr:last-child{border-style:var(--tw-border-style);border-width:0}.\[\&\:has\(\[role\=checkbox\]\)\]\:pr-0:has([role=checkbox]){padding-right:calc(var(--spacing) * 0)}.\[\.border-b\]\:pb-6.border-b{padding-bottom:calc(var(--spacing) * 6)}.\[\.border-t\]\:pt-6.border-t{padding-top:calc(var(--spacing) * 6)}:is(.\*\:\[span\]\:last\:flex>*):is(span):last-child{display:flex}:is(.\*\:\[span\]\:last\:items-center>*):is(span):last-child{align-items:center}:is(.\*\:\[span\]\:last\:gap-2>*):is(span):last-child{gap:calc(var(--spacing) * 2)}.\[\&\:last-child\]\:pb-6:last-child{padding-bottom:calc(var(--spacing) * 6)}.\[\&\>\[role\=checkbox\]\]\:translate-y-\[2px\]>[role=checkbox]{--tw-translate-y: 2px;translate:var(--tw-translate-x) var(--tw-translate-y)}.\[\&\>svg\]\:pointer-events-none>svg{pointer-events:none}.\[\&\>svg\]\:size-3>svg{width:calc(var(--spacing) * 3);height:calc(var(--spacing) * 3)}.\[\&\>tr\]\:last\:border-b-0>tr:last-child{border-bottom-style:var(--tw-border-style);border-bottom-width:0}@media(hover:hover){a.\[a\&\]\:hover\:bg-accent:hover{background-color:var(--accent)}}@media(hover:hover){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:var(--destructive)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-destructive\/90:hover{background-color:color-mix(in oklab,var(--destructive) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:var(--primary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-primary\/90:hover{background-color:color-mix(in oklab,var(--primary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:var(--secondary)}@supports (color: color-mix(in lab,red,red)){a.\[a\&\]\:hover\:bg-secondary\/90:hover{background-color:color-mix(in oklab,var(--secondary) 90%,transparent)}}}@media(hover:hover){a.\[a\&\]\:hover\:text-accent-foreground:hover{color:var(--accent-foreground)}}}:root{--font-size: 16px;--background: #fff;--foreground: oklch(.145 0 0);--card: #fff;--card-foreground: oklch(.145 0 0);--popover: oklch(1 0 0);--popover-foreground: oklch(.145 0 0);--primary: #030213;--primary-foreground: oklch(1 0 0);--secondary: oklch(.95 .0058 264.53);--secondary-foreground: #030213;--muted: #ececf0;--muted-foreground: #717182;--accent: #e9ebef;--accent-foreground: #030213;--destructive: #d4183d;--destructive-foreground: #fff;--border: #0000001a;--input: transparent;--input-background: #f3f3f5;--switch-background: #cbced4;--font-weight-medium: 500;--font-weight-normal: 400;--ring: oklch(.708 0 0);--chart-1: oklch(.646 .222 41.116);--chart-2: oklch(.6 .118 184.704);--chart-3: oklch(.398 .07 227.392);--chart-4: oklch(.828 .189 84.429);--chart-5: oklch(.769 .188 70.08);--radius: .625rem;--sidebar: oklch(.985 0 0);--sidebar-foreground: oklch(.145 0 0);--sidebar-primary: #030213;--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.97 0 0);--sidebar-accent-foreground: oklch(.205 0 0);--sidebar-border: oklch(.922 0 0);--sidebar-ring: oklch(.708 0 0)}.dark{--background: oklch(.145 0 0);--foreground: oklch(.985 0 0);--card: oklch(.145 0 0);--card-foreground: oklch(.985 0 0);--popover: oklch(.145 0 0);--popover-foreground: oklch(.985 0 0);--primary: oklch(.985 0 0);--primary-foreground: oklch(.205 0 0);--secondary: oklch(.269 0 0);--secondary-foreground: oklch(.985 0 0);--muted: oklch(.269 0 0);--muted-foreground: oklch(.708 0 0);--accent: oklch(.269 0 0);--accent-foreground: oklch(.985 0 0);--destructive: oklch(.396 .141 25.723);--destructive-foreground: oklch(.637 .237 25.331);--border: oklch(.269 0 0);--input: oklch(.269 0 0);--ring: oklch(.439 0 0);--font-weight-medium: 500;--font-weight-normal: 400;--chart-1: oklch(.488 .243 264.376);--chart-2: oklch(.696 .17 162.48);--chart-3: oklch(.769 .188 70.08);--chart-4: oklch(.627 .265 303.9);--chart-5: oklch(.645 .246 16.439);--sidebar: oklch(.205 0 0);--sidebar-foreground: oklch(.985 0 0);--sidebar-primary: oklch(.488 .243 264.376);--sidebar-primary-foreground: oklch(.985 0 0);--sidebar-accent: oklch(.269 0 0);--sidebar-accent-foreground: oklch(.985 0 0);--sidebar-border: oklch(.269 0 0);--sidebar-ring: oklch(.439 0 0)}html{font-size:var(--font-size)}@property --tw-translate-x{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-y{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-translate-z{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-rotate-x{syntax: "*"; inherits: false; initial-value: rotateX(0);}@property --tw-rotate-y{syntax: "*"; inherits: false; initial-value: rotateY(0);}@property --tw-rotate-z{syntax: "*"; inherits: false; initial-value: rotateZ(0);}@property --tw-skew-x{syntax: "*"; inherits: false; initial-value: skewX(0);}@property --tw-skew-y{syntax: "*"; inherits: false; initial-value: skewY(0);}@property --tw-space-y-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-space-x-reverse{syntax: "*"; inherits: false; initial-value: 0;}@property --tw-border-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-gradient-position{syntax: "*"; inherits: false}@property --tw-gradient-from{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-via{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-to{syntax: "<color>"; inherits: false; initial-value: #0000;}@property --tw-gradient-stops{syntax: "*"; inherits: false}@property --tw-gradient-via-stops{syntax: "*"; inherits: false}@property --tw-gradient-from-position{syntax: "<length-percentage>"; inherits: false; initial-value: 0%;}@property --tw-gradient-via-position{syntax: "<length-percentage>"; inherits: false; initial-value: 50%;}@property --tw-gradient-to-position{syntax: "<length-percentage>"; inherits: false; initial-value: 100%;}@property --tw-leading{syntax: "*"; inherits: false}@property --tw-font-weight{syntax: "*"; inherits: false}@property --tw-tracking{syntax: "*"; inherits: false}@property --tw-ordinal{syntax: "*"; inherits: false}@property --tw-slashed-zero{syntax: "*"; inherits: false}@property --tw-numeric-figure{syntax: "*"; inherits: false}@property --tw-numeric-spacing{syntax: "*"; inherits: false}@property --tw-numeric-fraction{syntax: "*"; inherits: false}@property --tw-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-shadow-color{syntax: "*"; inherits: false}@property --tw-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-inset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-shadow-color{syntax: "*"; inherits: false}@property --tw-inset-shadow-alpha{syntax: "<percentage>"; inherits: false; initial-value: 100%;}@property --tw-ring-color{syntax: "*"; inherits: false}@property --tw-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-inset-ring-color{syntax: "*"; inherits: false}@property --tw-inset-ring-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-ring-inset{syntax: "*"; inherits: false}@property --tw-ring-offset-width{syntax: "<length>"; inherits: false; initial-value: 0;}@property --tw-ring-offset-color{syntax: "*"; inherits: false; initial-value: #fff;}@property --tw-ring-offset-shadow{syntax: "*"; inherits: false; initial-value: 0 0 #0000;}@property --tw-outline-style{syntax: "*"; inherits: false; initial-value: solid;}@property --tw-backdrop-blur{syntax: "*"; inherits: false}@property --tw-backdrop-brightness{syntax: "*"; inherits: false}@property --tw-backdrop-contrast{syntax: "*"; inherits: false}@property --tw-backdrop-grayscale{syntax: "*"; inherits: false}@property --tw-backdrop-hue-rotate{syntax: "*"; inherits: false}@property --tw-backdrop-invert{syntax: "*"; inherits: false}@property --tw-backdrop-opacity{syntax: "*"; inherits: false}@property --tw-backdrop-saturate{syntax: "*"; inherits: false}@property --tw-backdrop-sepia{syntax: "*"; inherits: false}@property --tw-duration{syntax: "*"; inherits: false}@property --tw-ease{syntax: "*"; inherits: false}@property --tw-scale-x{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-y{syntax: "*"; inherits: false; initial-value: 1;}@property --tw-scale-z{syntax: "*"; inherits: false; initial-value: 1;}@keyframes pulse{50%{opacity:.5}}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}[data-sonner-toaster]{z-index:10000!important}.category-appearance-toggles button[data-slot=toggle-group-item][data-state=on]{background-color:#2563eb!important;border-color:#2563eb!important;color:#fff!important}.category-appearance-toggles button[data-slot=toggle-group-item][data-state=on]:hover{background-color:#1d4ed8!important;border-color:#1d4ed8!important;color:#fff!important}.category-appearance-toggles button[data-slot=toggle-group-item][data-state=on] svg{color:inherit}[data-slot=dialog-content].form-dialog-shell{display:grid!important;height:70vh!important;max-height:70vh!important;min-height:0!important;width:min(50%,calc(100vw - 2rem))!important;max-width:min(50%,calc(100vw - 2rem))!important;overflow:hidden!important;padding:0!important;box-sizing:border-box!important}[data-slot=dialog-content].form-dialog-shell>.form-dialog-scroll-body{min-height:0!important;max-height:100%!important;overflow-y:auto!important;overflow-x:hidden!important}.form-dialog-scroll-body img{max-width:100%;max-height:120px;object-fit:contain}.form-dialog-scroll-body .group.relative{max-width:100%}@font-face{font-family:FreightSans Bold;src:url(/assets/FreightSans%20Bold-CftzBXfG.ttf) format("truetype");font-weight:700;font-style:normal;font-display:swap}:root{--font-sans: "FreightSans Bold", ui-sans-serif, system-ui, sans-serif;--font-numeric: var(--font-sans)}body{font-family:var(--font-sans)}.font-numeric{font-family:var(--font-sans)!important;font-variant-numeric:tabular-nums}@font-face{font-family:Roboto;src:url(/assets/roboto-latin-400-normal-CNwBRw8h.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Roboto;src:url(/assets/roboto-latin-700-normal-DZr4b_KL.woff2) format("woff2");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Roboto;src:url(/assets/roboto-latin-400-italic-CdnZD53w.woff2) format("woff2");font-weight:400;font-style:italic;font-display:swap}@font-face{font-family:Open Sans;src:url(/assets/open-sans-latin-400-normal-Cjao0ETp.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Open Sans;src:url(/assets/open-sans-latin-700-normal-C2okHfb_.woff2) format("woff2");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Open Sans;src:url(/assets/open-sans-latin-400-italic-Cl3bbQIm.woff2) format("woff2");font-weight:400;font-style:italic;font-display:swap}@font-face{font-family:Lato;src:url(/assets/lato-latin-400-normal-BEhtfm5r.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Lato;src:url(/assets/lato-latin-700-normal-BUGMgin4.woff2) format("woff2");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Lato;src:url(/assets/lato-latin-400-italic-Dc0B1559.woff2) format("woff2");font-weight:400;font-style:italic;font-display:swap}@font-face{font-family:Tinos;src:url(/assets/tinos-latin-400-normal-CFrhwyB3.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Tinos;src:url(/assets/tinos-latin-700-normal-B08IChYM.woff2) format("woff2");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Tinos;src:url(/assets/tinos-latin-400-italic-BsSSA_Bs.woff2) format("woff2");font-weight:400;font-style:italic;font-display:swap}@font-face{font-family:Roboto Mono;src:url(/assets/roboto-mono-latin-400-normal-C_5wUCW5.woff2) format("woff2");font-weight:400;font-style:normal;font-display:swap}@font-face{font-family:Roboto Mono;src:url(/assets/roboto-mono-latin-700-normal-DpzZ8rK9.woff2) format("woff2");font-weight:700;font-style:normal;font-display:swap}@font-face{font-family:Roboto Mono;src:url(/assets/roboto-mono-latin-400-italic-B6BBQVPU.woff2) format("woff2");font-weight:400;font-style:italic;font-display:swap}
美国版/Food Labeling Management Platform/build/assets/index-CydGaE-m.js deleted
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/index-Drb6pFBd.js 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/lato-latin-400-italic-Dc0B1559.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/lato-latin-400-normal-BEhtfm5r.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/lato-latin-700-normal-BUGMgin4.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/open-sans-latin-400-italic-Cl3bbQIm.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/open-sans-latin-400-normal-Cjao0ETp.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/open-sans-latin-700-normal-C2okHfb_.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/roboto-latin-400-italic-CdnZD53w.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/roboto-latin-400-normal-CNwBRw8h.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/roboto-latin-700-normal-DZr4b_KL.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/roboto-mono-latin-400-italic-B6BBQVPU.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/roboto-mono-latin-400-normal-C_5wUCW5.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/roboto-mono-latin-700-normal-DpzZ8rK9.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/tinos-latin-400-italic-BsSSA_Bs.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/tinos-latin-400-normal-CFrhwyB3.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/assets/tinos-latin-700-normal-B08IChYM.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/build/index.html
@@ -5,8 +5,8 @@ @@ -5,8 +5,8 @@
5 <meta charset="UTF-8" /> 5 <meta charset="UTF-8" />
6 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 <title>Food Labeling Management Platform</title> 7 <title>Food Labeling Management Platform</title>
8 - <script type="module" crossorigin src="/assets/index-CydGaE-m.js"></script>  
9 - <link rel="stylesheet" crossorigin href="/assets/index-BFajuDEY.css"> 8 + <script type="module" crossorigin src="/assets/index-Drb6pFBd.js"></script>
  9 + <link rel="stylesheet" crossorigin href="/assets/index-C6CSIunP.css">
10 </head> 10 </head>
11 11
12 <body> 12 <body>
美国版/Food Labeling Management Platform/src/assets/fonts/lato/lato-latin-400-italic.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/lato/lato-latin-400-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/lato/lato-latin-700-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/open-sans/open-sans-latin-400-italic.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/open-sans/open-sans-latin-400-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/open-sans/open-sans-latin-700-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/roboto-mono/roboto-mono-latin-400-italic.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/roboto-mono/roboto-mono-latin-400-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/roboto-mono/roboto-mono-latin-700-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/roboto/roboto-latin-400-italic.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/roboto/roboto-latin-400-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/roboto/roboto-latin-700-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/tinos/tinos-latin-400-italic.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/tinos/tinos-latin-400-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/assets/fonts/tinos/tinos-latin-700-normal.woff2 0 → 100644
No preview for this file type
美国版/Food Labeling Management Platform/src/components/labels/LabelCategoriesView.tsx
@@ -236,7 +236,7 @@ function renderCategoryPhotoCell(item: LabelCategoryDto) { @@ -236,7 +236,7 @@ function renderCategoryPhotoCell(item: LabelCategoryDto) {
236 if (visual.mode === "none") { 236 if (visual.mode === "none") {
237 return <span className="text-sm font-normal text-gray-900">—</span>; 237 return <span className="text-sm font-normal text-gray-900">—</span>;
238 } 238 }
239 - return <CategoryButtonVisualThumb visual={visual} size="md" />; 239 + return <CategoryButtonVisualThumb visual={visual} variant="labelCategory" />;
240 } 240 }
241 241
242 export function LabelCategoriesView() { 242 export function LabelCategoriesView() {
@@ -1164,6 +1164,7 @@ function CreateLabelCategoryDialog({ @@ -1164,6 +1164,7 @@ function CreateLabelCategoryDialog({
1164 ) : null} 1164 ) : null}
1165 1165
1166 <CategoryButtonAppearancePreview 1166 <CategoryButtonAppearancePreview
  1167 + variant="labelCategory"
1167 apSel={apSel} 1168 apSel={apSel}
1168 displayText={displayTextForPhoto} 1169 displayText={displayTextForPhoto}
1169 buttonBgColor={buttonBgColor} 1170 buttonBgColor={buttonBgColor}
@@ -1635,6 +1636,7 @@ function EditLabelCategoryDialog({ @@ -1635,6 +1636,7 @@ function EditLabelCategoryDialog({
1635 ) : null} 1636 ) : null}
1636 1637
1637 <CategoryButtonAppearancePreview 1638 <CategoryButtonAppearancePreview
  1639 + variant="labelCategory"
1638 apSel={apSel} 1640 apSel={apSel}
1639 displayText={displayTextForPhoto} 1641 displayText={displayTextForPhoto}
1640 buttonBgColor={buttonBgColor} 1642 buttonBgColor={buttonBgColor}
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx
@@ -47,6 +47,7 @@ import { @@ -47,6 +47,7 @@ import {
47 nutritionCompositeFieldKey, 47 nutritionCompositeFieldKey,
48 type NutritionManualFieldSpec, 48 type NutritionManualFieldSpec,
49 } from '../../lib/nutritionManualEntry'; 49 } from '../../lib/nutritionManualEntry';
  50 +import { NutritionManualFieldControl } from './NutritionManualFieldControl';
50 import type { ProductDto } from '../../types/product'; 51 import type { ProductDto } from '../../types/product';
51 import type { LabelTypeDto } from '../../types/labelType'; 52 import type { LabelTypeDto } from '../../types/labelType';
52 import { 53 import {
@@ -548,7 +549,7 @@ export function LabelTemplateDataEntryView({ @@ -548,7 +549,7 @@ export function LabelTemplateDataEntryView({
548 className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap" 549 className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap"
549 title={col.kind === 'element' ? col.el.id : `${col.parent.id} · ${col.spec.subKey}`} 550 title={col.kind === 'element' ? col.el.id : `${col.parent.id} · ${col.spec.subKey}`}
550 > 551 >
551 - {col.kind === 'element' ? dataEntryColumnLabel(col.el) : col.spec.columnLabel} 552 + {col.kind === 'element' ? dataEntryColumnLabel(col.el, sortedTemplateElements) : col.spec.columnLabel}
552 </TableHead> 553 </TableHead>
553 ))} 554 ))}
554 <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead> 555 <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead>
@@ -591,21 +592,22 @@ export function LabelTemplateDataEntryView({ @@ -591,21 +592,22 @@ export function LabelTemplateDataEntryView({
591 onValueChange={(v) => setFieldValue(row.id, col.el.id, v)} 592 onValueChange={(v) => setFieldValue(row.id, col.el.id, v)}
592 /> 593 />
593 ) : ( 594 ) : (
594 - <Input 595 + <NutritionManualFieldControl
  596 + spec={col.spec}
  597 + showLabel={false}
595 value={ 598 value={
596 row.fieldValues[ 599 row.fieldValues[
597 nutritionCompositeFieldKey(col.parent.id, col.spec.subKey) 600 nutritionCompositeFieldKey(col.parent.id, col.spec.subKey)
598 ] ?? '' 601 ] ?? ''
599 } 602 }
600 - onChange={(e) => 603 + onChange={(next) =>
601 setFieldValue( 604 setFieldValue(
602 row.id, 605 row.id,
603 nutritionCompositeFieldKey(col.parent.id, col.spec.subKey), 606 nutritionCompositeFieldKey(col.parent.id, col.spec.subKey),
604 - e.target.value, 607 + next,
605 ) 608 )
606 } 609 }
607 - placeholder="—"  
608 - className="h-10 border-gray-300 max-w-[220px]" 610 + inputClassName="h-10 border-gray-300 max-w-[220px]"
609 /> 611 />
610 )} 612 )}
611 </TableCell> 613 </TableCell>
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/ElementsPanel.tsx
1 import React from "react"; 1 import React from "react";
2 -import { Info } from "lucide-react";  
3 import type { ElementLibraryCategory, ElementType } from "../../../types/labelTemplate"; 2 import type { ElementLibraryCategory, ElementType } from "../../../types/labelTemplate";
4 import { 3 import {
5 elementLibraryCategoryPanelTitle, 4 elementLibraryCategoryPanelTitle,
@@ -84,6 +83,7 @@ interface ElementsPanelProps { @@ -84,6 +83,7 @@ interface ElementsPanelProps {
84 libraryCategory: ElementLibraryCategory, 83 libraryCategory: ElementLibraryCategory,
85 paletteItemLabel: string, 84 paletteItemLabel: string,
86 ) => void; 85 ) => void;
  86 + onOpenInstruction: () => void;
87 } 87 }
88 88
89 /** 各分组:无底色,蓝色细实线边框 */ 89 /** 各分组:无底色,蓝色细实线边框 */
@@ -96,60 +96,17 @@ function sectionSurfaceStyle(): React.CSSProperties { @@ -96,60 +96,17 @@ function sectionSurfaceStyle(): React.CSSProperties {
96 }; 96 };
97 } 97 }
98 98
99 -const ELEMENT_LIBRARY_HELP_SECTIONS = [  
100 - {  
101 - title: "For Template",  
102 - body:  
103 - "When you add these items to your template, the text will be the same on all labels that use this template.",  
104 - },  
105 - {  
106 - title: "For Label",  
107 - body:  
108 - "When you add these items to your template, the text will be different on each of the labels that use this template. For example, if you use Label Name as one of your items, one label may have the text 'Chicken' and another 'Pork.'",  
109 - },  
110 - {  
111 - title: "Entered Automatically",  
112 - body:  
113 - "When you add these items to your template, the printed text will be automatically changed according to various factors (location, employee, date, time, product, etc.).",  
114 - },  
115 - {  
116 - title: "Entered When Printing",  
117 - body:  
118 - "The person printing the label will use these items to add information to the label at the time of printing.",  
119 - },  
120 -] as const;  
121 -  
122 -function ElementsPanelHelp() {  
123 - return (  
124 - <div className="mx-2 mb-3 flex gap-2 rounded-md bg-gray-100 px-3 py-3">  
125 - <Info className="mt-0.5 h-4 w-4 shrink-0 text-gray-500" aria-hidden />  
126 - <div className="min-w-0 space-y-3 text-[11px] leading-relaxed text-gray-600">  
127 - <p>  
128 - Click on an item above to add it to your template. Once an item has been placed into your  
129 - template, there may be additional options for you to select.  
130 - </p>  
131 - {ELEMENT_LIBRARY_HELP_SECTIONS.map((section) => (  
132 - <div key={section.title}>  
133 - <p className="font-semibold text-gray-700">{section.title}</p>  
134 - <p className="mt-0.5">{section.body}</p>  
135 - </div>  
136 - ))}  
137 - </div>  
138 - </div>  
139 - );  
140 -}  
141 -  
142 /** 99 /**
143 * 左侧元素库。纵向滚动由父级(index 左侧列容器)的 overflow-y:auto 负责, 100 * 左侧元素库。纵向滚动由父级(index 左侧列容器)的 overflow-y:auto 负责,
144 * 避免预编译 CSS 缺少 min-h-0 / flex-1 时内层滚动高度为 0 导致底部橙色区被裁切。 101 * 避免预编译 CSS 缺少 min-h-0 / flex-1 时内层滚动高度为 0 导致底部橙色区被裁切。
145 */ 102 */
146 -export function ElementsPanel({ onAddElement }: ElementsPanelProps) { 103 +export function ElementsPanel({ onAddElement, onOpenInstruction }: ElementsPanelProps) {
147 return ( 104 return (
148 <div className="border-r border-slate-200 bg-slate-50"> 105 <div className="border-r border-slate-200 bg-slate-50">
149 <div className="border-b border-[#93c5fd] bg-white px-2 py-2 text-sm font-semibold text-slate-800"> 106 <div className="border-b border-[#93c5fd] bg-white px-2 py-2 text-sm font-semibold text-slate-800">
150 Elements 107 Elements
151 </div> 108 </div>
152 - <div className="p-2 space-y-3"> 109 + <div className="space-y-3 p-2">
153 {ELEMENT_CATEGORIES.map((cat) => ( 110 {ELEMENT_CATEGORIES.map((cat) => (
154 <div key={cat.title} style={sectionSurfaceStyle()}> 111 <div key={cat.title} style={sectionSurfaceStyle()}>
155 <div className="px-2 py-1 text-xs font-semibold leading-snug text-gray-700"> 112 <div className="px-2 py-1 text-xs font-semibold leading-snug text-gray-700">
@@ -186,7 +143,15 @@ export function ElementsPanel({ onAddElement }: ElementsPanelProps) { @@ -186,7 +143,15 @@ export function ElementsPanel({ onAddElement }: ElementsPanelProps) {
186 </div> 143 </div>
187 </div> 144 </div>
188 ))} 145 ))}
189 - <ElementsPanelHelp /> 146 + <div style={sectionSurfaceStyle()}>
  147 + <button
  148 + type="button"
  149 + className="w-full rounded px-2 py-1.5 text-left text-xs font-medium text-gray-900 hover:bg-gray-100"
  150 + onClick={onOpenInstruction}
  151 + >
  152 + Instruction
  153 + </button>
  154 + </div>
190 </div> 155 </div>
191 </div> 156 </div>
192 ); 157 );
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/LabelCanvas.tsx
@@ -4,14 +4,17 @@ import { QRCodeSVG } from &#39;qrcode.react&#39;; @@ -4,14 +4,17 @@ import { QRCodeSVG } from &#39;qrcode.react&#39;;
4 import type { 4 import type {
5 LabelTemplate, 5 LabelTemplate,
6 LabelElement, 6 LabelElement,
7 - NutritionExtraItem,  
8 PrintOrientation, 7 PrintOrientation,
9 } from '../../../types/labelTemplate'; 8 } from '../../../types/labelTemplate';
10 import { 9 import {
11 canonicalElementType, 10 canonicalElementType,
12 isPrintInputElement, 11 isPrintInputElement,
13 isCompanyAutoElement, 12 isCompanyAutoElement,
  13 + readElementPositionLocked,
  14 + patchElementPositionLocked,
14 resolveLabelEditorElementFontFamily, 15 resolveLabelEditorElementFontFamily,
  16 + parseElementPaletteContext,
  17 + inferPaletteEnglishLabel,
15 } from '../../../types/labelTemplate'; 18 } from '../../../types/labelTemplate';
16 import { 19 import {
17 INVERT_COLORS_BG, 20 INVERT_COLORS_BG,
@@ -19,9 +22,10 @@ import { @@ -19,9 +22,10 @@ import {
19 readInvertColors, 22 readInvertColors,
20 } from '../../../utils/invertColorsConfig'; 23 } from '../../../utils/invertColorsConfig';
21 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate'; 24 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate';
22 -import { NUTRITION_FIXED_ITEMS } from '../../../types/labelTemplate';  
23 import { cn } from '../../ui/utils'; 25 import { cn } from '../../ui/utils';
24 import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService'; 26 import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService';
  27 +import { imageScaleModeImgClassName, readImageScaleMode } from '../../../utils/imageScaleMode';
  28 +import { formatWeightDisplay } from '../../../utils/weightElement';
25 import { 29 import {
26 Select, 30 Select,
27 SelectContent, 31 SelectContent,
@@ -40,6 +44,20 @@ import { @@ -40,6 +44,20 @@ import {
40 previewRulerUnitLabel, 44 previewRulerUnitLabel,
41 } from '@/utils/previewRulerUnits'; 45 } from '@/utils/previewRulerUnits';
42 import { isLikelyResolvedDateTimeLiteral } from '../../../lib/labelFormDatePreview'; 46 import { isLikelyResolvedDateTimeLiteral } from '../../../lib/labelFormDatePreview';
  47 +import { AvailableElementsPanel } from './available-elements-panel';
  48 +import { NutritionFactsPanel } from './NutritionFactsPanel';
  49 +import { Lock } from 'lucide-react';
  50 +import {
  51 + readVerticalAlign,
  52 + textAlignToFlexAlign,
  53 + verticalAlignToFlexJustify,
  54 + readElementRotation,
  55 + normalizeElementRotationBox,
  56 + canonicalElementGeometry,
  57 + readFontWeight,
  58 + readFontStyle,
  59 + readTextDecoration,
  60 +} from '../../../utils/textElementLayout';
43 61
44 export type { PrintOrientation } from '../../../types/labelTemplate'; 62 export type { PrintOrientation } from '../../../types/labelTemplate';
45 63
@@ -116,7 +134,7 @@ export function templatePositionFromPointerGrab( @@ -116,7 +134,7 @@ export function templatePositionFromPointerGrab(
116 }; 134 };
117 } 135 }
118 136
119 -const ELEMENT_DRAG_THRESHOLD_PX = 4; 137 +const ELEMENT_DRAG_THRESHOLD_PX = 2;
120 138
121 /** 横打预览 rotate(90deg) 时,屏幕拖拽增量映射到模板宽/高增量 */ 139 /** 横打预览 rotate(90deg) 时,屏幕拖拽增量映射到模板宽/高增量 */
122 export function elementResizeDeltaFromPointer( 140 export function elementResizeDeltaFromPointer(
@@ -178,12 +196,12 @@ export function computeResizedElementBox( @@ -178,12 +196,12 @@ export function computeResizedElementBox(
178 let ny = elY; 196 let ny = elY;
179 if (handleId.includes('e')) nw = Math.max(20, w + dw); 197 if (handleId.includes('e')) nw = Math.max(20, w + dw);
180 if (handleId.includes('w')) { 198 if (handleId.includes('w')) {
181 - nw = Math.max(20, w - dw); 199 + nw = Math.max(20, w + dw);
182 nx = elX + (w - nw); 200 nx = elX + (w - nw);
183 } 201 }
184 if (handleId.includes('s')) nh = Math.max(12, h + dh); 202 if (handleId.includes('s')) nh = Math.max(12, h + dh);
185 if (handleId.includes('n')) { 203 if (handleId.includes('n')) {
186 - nh = Math.max(12, h - dh); 204 + nh = Math.max(12, h + dh);
187 ny = elY + (h - nh); 205 ny = elY + (h - nh);
188 } 206 }
189 return { x: nx, y: ny, width: nw, height: nh }; 207 return { x: nx, y: ny, width: nw, height: nh };
@@ -470,6 +488,11 @@ export function mergeLabelElementLivePatch( @@ -470,6 +488,11 @@ export function mergeLabelElementLivePatch(
470 return { ...el, ...patch }; 488 return { ...el, ...patch };
471 } 489 }
472 490
  491 +/** 竖排且宽>高时纠正选框(仅用于落库/渲染,不在 live patch 合并时调用) */
  492 +function displayElementGeometry(el: LabelElement): LabelElement {
  493 + return canonicalElementGeometry(el);
  494 +}
  495 +
473 export function mergeTemplateElementsLivePatch( 496 export function mergeTemplateElementsLivePatch(
474 elements: LabelElement[], 497 elements: LabelElement[],
475 patch: LabelElementLivePatch | null | undefined, 498 patch: LabelElementLivePatch | null | undefined,
@@ -509,7 +532,7 @@ function formatSelectionLengthForPreviewRuler( @@ -509,7 +532,7 @@ function formatSelectionLengthForPreviewRuler(
509 } 532 }
510 533
511 /** 画布选中框旁尺寸读数(仅数值,与参考图一致) */ 534 /** 画布选中框旁尺寸读数(仅数值,与参考图一致) */
512 -function formatElementDimensionValue( 535 +function formatElementDimensionLabelValue(
513 lengthPx: number, 536 lengthPx: number,
514 basePaperPx: number, 537 basePaperPx: number,
515 paperSizeTemplate: number, 538 paperSizeTemplate: number,
@@ -523,11 +546,55 @@ function formatElementDimensionValue( @@ -523,11 +546,55 @@ function formatElementDimensionValue(
523 templateUnit, 546 templateUnit,
524 displayUnit, 547 displayUnit,
525 ); 548 );
526 - return formatPreviewRulerDisplayValue(d, displayUnit); 549 + if (!Number.isFinite(d)) return "";
  550 + if (displayUnit === "mm") return String(Math.round(d * 10) / 10);
  551 + return d.toFixed(4);
527 } 552 }
528 553
529 -const ELEMENT_RESIZE_HANDLE_SIZE = 8;  
530 -const ELEMENT_RESIZE_HANDLE_HIT = 14; 554 +const ELEMENT_RESIZE_HANDLE_SIZE = 4;
  555 +const ELEMENT_RESIZE_HANDLE_HIT = 7;
  556 +/** 拖拽控制点:饱和蓝 + 白环,在浅色网格上更易辨认 */
  557 +const ELEMENT_RESIZE_HANDLE_FILL = '#2563eb';
  558 +const ELEMENT_RESIZE_HANDLE_SHADOW =
  559 + '0 0 0 1px #ffffff, 0 0 0 2px #1d4ed8, 0 1px 3px rgba(29, 78, 216, 0.5)';
  560 +/** 选框、对齐参考线线宽 */
  561 +const ELEMENT_SELECTION_LINE_WIDTH = 0.5;
  562 +/** 选中控件虚线框(里框)略加粗、深蓝,便于在网格上辨认 */
  563 +const ELEMENT_SELECTION_FRAME_STROKE = '#2563eb';
  564 +const ELEMENT_SELECTION_FRAME_STROKE_WIDTH = 1;
  565 +const ELEMENT_SELECTION_FRAME_DASH = '4 2';
  566 +/** 选中框旁宽/高读数字号 */
  567 +const ELEMENT_DIMENSION_LABEL_FONT_SIZE = 8;
  568 +/** 尺寸数字与选框间距(略远,避免贴边难读) */
  569 +const ELEMENT_DIMENSION_LABEL_GAP = 6;
  570 +/** 整标签纸外框(模板 Border:Line / Dotted),默认 1px 在网格上不易辨认 */
  571 +const TEMPLATE_PAPER_BORDER_WIDTH_PX = 2;
  572 +const TEMPLATE_PAPER_BORDER_COLOR = '#374151';
  573 +
  574 +function resolveTemplatePaperBorder(
  575 + border: 'none' | 'line' | 'dotted' | string | undefined,
  576 +): { className: string; style: React.CSSProperties } {
  577 + if (border === 'line') {
  578 + return {
  579 + className: 'box-border',
  580 + style: {
  581 + border: `${TEMPLATE_PAPER_BORDER_WIDTH_PX}px solid ${TEMPLATE_PAPER_BORDER_COLOR}`,
  582 + },
  583 + };
  584 + }
  585 + if (border === 'dotted') {
  586 + return {
  587 + className: 'box-border',
  588 + style: {
  589 + border: `${TEMPLATE_PAPER_BORDER_WIDTH_PX}px dotted ${TEMPLATE_PAPER_BORDER_COLOR}`,
  590 + },
  591 + };
  592 + }
  593 + return {
  594 + className: 'border border-transparent',
  595 + style: {},
  596 + };
  597 +}
531 const ELEMENT_RESIZE_HANDLES = [ 598 const ELEMENT_RESIZE_HANDLES = [
532 { id: "nw", cx: 0, cy: 0, cursor: "nwse-resize" }, 599 { id: "nw", cx: 0, cy: 0, cursor: "nwse-resize" },
533 { id: "n", cx: 0.5, cy: 0, cursor: "ns-resize" }, 600 { id: "n", cx: 0.5, cy: 0, cursor: "ns-resize" },
@@ -539,19 +606,47 @@ const ELEMENT_RESIZE_HANDLES = [ @@ -539,19 +606,47 @@ const ELEMENT_RESIZE_HANDLES = [
539 { id: "w", cx: 0, cy: 0.5, cursor: "ew-resize" }, 606 { id: "w", cx: 0, cy: 0.5, cursor: "ew-resize" },
540 ] as const; 607 ] as const;
541 608
542 -function dimensionArrowHead(  
543 - tipX: number,  
544 - tipY: number,  
545 - dir: "left" | "right" | "up" | "down",  
546 -): string {  
547 - const s = 3;  
548 - if (dir === "left") return `${tipX},${tipY} ${tipX + s},${tipY - s} ${tipX + s},${tipY + s}`;  
549 - if (dir === "right") return `${tipX},${tipY} ${tipX - s},${tipY - s} ${tipX - s},${tipY + s}`;  
550 - if (dir === "up") return `${tipX},${tipY} ${tipX - s},${tipY + s} ${tipX + s},${tipY + s}`;  
551 - return `${tipX},${tipY} ${tipX - s},${tipY - s} ${tipX + s},${tipY - s}`; 609 +function elementResizeHandlesForBox(
  610 + w: number,
  611 + h: number,
  612 + rotation?: string | null,
  613 +) {
  614 + const isVertical = readElementRotation({ rotation }) === 'vertical';
  615 + return ELEMENT_RESIZE_HANDLES.filter((handle) => {
  616 + if (isVertical && (handle.id === 'w' || handle.id === 'e')) {
  617 + return false;
  618 + }
  619 + if (w < ELEMENT_RESIZE_HANDLE_HIT * 2 && (handle.id === 'w' || handle.id === 'e')) {
  620 + return false;
  621 + }
  622 + if (h < ELEMENT_RESIZE_HANDLE_HIT * 2 && (handle.id === 'n' || handle.id === 's')) {
  623 + return false;
  624 + }
  625 + return true;
  626 + });
552 } 627 }
553 628
554 -/** 选中控件:方形虚线框 + 宽高标注 + 8 个红色拖拽点 */ 629 +/** 竖排时选框应为窄×高,与文字方向一致 */
  630 +export function elementDragHitBounds(
  631 + el: Pick<LabelElement, 'x' | 'y' | 'width' | 'height' | 'rotation'>,
  632 +): { x: number; y: number; width: number; height: number } {
  633 + const normalized = displayElementGeometry({
  634 + ...el,
  635 + rotation: el.rotation ?? 'horizontal',
  636 + x: el.x,
  637 + y: el.y,
  638 + width: Math.max(1, el.width),
  639 + height: Math.max(1, el.height),
  640 + } as LabelElement);
  641 + return {
  642 + x: normalized.x,
  643 + y: normalized.y,
  644 + width: normalized.width,
  645 + height: normalized.height,
  646 + };
  647 +}
  648 +
  649 +/** 选中控件:方形虚线框 + 宽高数字 + 8 个蓝色拖拽点 */
555 function ElementSelectionFrame({ 650 function ElementSelectionFrame({
556 el, 651 el,
557 templateUnit, 652 templateUnit,
@@ -563,6 +658,8 @@ function ElementSelectionFrame({ @@ -563,6 +658,8 @@ function ElementSelectionFrame({
563 printOrientation = 'vertical', 658 printOrientation = 'vertical',
564 interactive = false, 659 interactive = false,
565 onResizePointerDown, 660 onResizePointerDown,
  661 + onDragPointerDown,
  662 + positionLocked = false,
566 }: { 663 }: {
567 el: LabelElement; 664 el: LabelElement;
568 templateUnit: "cm" | "inch"; 665 templateUnit: "cm" | "inch";
@@ -574,19 +671,21 @@ function ElementSelectionFrame({ @@ -574,19 +671,21 @@ function ElementSelectionFrame({
574 printOrientation?: PrintOrientation; 671 printOrientation?: PrintOrientation;
575 interactive?: boolean; 672 interactive?: boolean;
576 onResizePointerDown?: (e: React.PointerEvent, handleId: string) => void; 673 onResizePointerDown?: (e: React.PointerEvent, handleId: string) => void;
  674 + onDragPointerDown?: (e: React.PointerEvent) => void;
  675 + positionLocked?: boolean;
577 }) { 676 }) {
578 const x = el.x; 677 const x = el.x;
579 const y = el.y; 678 const y = el.y;
580 const w = Math.max(1, el.width); 679 const w = Math.max(1, el.width);
581 const h = Math.max(1, el.height); 680 const h = Math.max(1, el.height);
582 - const wValue = formatElementDimensionValue( 681 + const wValue = formatElementDimensionLabelValue(
583 w, 682 w,
584 paperWidthPx, 683 paperWidthPx,
585 paperWidthTemplate, 684 paperWidthTemplate,
586 templateUnit, 685 templateUnit,
587 displayUnit, 686 displayUnit,
588 ); 687 );
589 - const hValue = formatElementDimensionValue( 688 + const hValue = formatElementDimensionLabelValue(
590 h, 689 h,
591 paperHeightPx, 690 paperHeightPx,
592 paperHeightTemplate, 691 paperHeightTemplate,
@@ -594,20 +693,39 @@ function ElementSelectionFrame({ @@ -594,20 +693,39 @@ function ElementSelectionFrame({
594 displayUnit, 693 displayUnit,
595 ); 694 );
596 695
597 - const widthAbove = y >= 28;  
598 - const widthLineY = widthAbove ? y - 12 : y + h + 12;  
599 - const widthTextY = widthAbove ? widthLineY - 6 : widthLineY + 14; 696 + const widthAbove = y >= ELEMENT_DIMENSION_LABEL_FONT_SIZE + ELEMENT_DIMENSION_LABEL_GAP + 4;
  697 + const widthTextY = widthAbove
  698 + ? y - ELEMENT_DIMENSION_LABEL_GAP
  699 + : y + h + ELEMENT_DIMENSION_LABEL_FONT_SIZE + ELEMENT_DIMENSION_LABEL_GAP;
600 700
601 - const heightOnLeft = x >= 36;  
602 - const heightLineX = heightOnLeft ? x - 12 : x + w + 12;  
603 - const heightTextX = heightOnLeft ? heightLineX - 8 : heightLineX + 8; 701 + const heightOnLeft = x >= ELEMENT_DIMENSION_LABEL_FONT_SIZE + ELEMENT_DIMENSION_LABEL_GAP + 16;
  702 + const heightTextX = heightOnLeft
  703 + ? x - ELEMENT_DIMENSION_LABEL_GAP
  704 + : x + w + ELEMENT_DIMENSION_LABEL_GAP;
604 // 横打预览旋转后,屏幕上的横/竖跨度与模板宽/高对调,标注跟随视觉 705 // 横打预览旋转后,屏幕上的横/竖跨度与模板宽/高对调,标注跟随视觉
605 const isRotatedPrint = printOrientation === 'horizontal'; 706 const isRotatedPrint = printOrientation === 'horizontal';
606 const displayWidthLabel = isRotatedPrint ? hValue : wValue; 707 const displayWidthLabel = isRotatedPrint ? hValue : wValue;
607 const displayHeightLabel = isRotatedPrint ? wValue : hValue; 708 const displayHeightLabel = isRotatedPrint ? wValue : hValue;
  709 + const resizeHandles = elementResizeHandlesForBox(w, h, el.rotation);
  710 + const dragHit = elementDragHitBounds(el);
608 711
609 return ( 712 return (
610 <> 713 <>
  714 + {interactive && !positionLocked && onDragPointerDown ? (
  715 + <div
  716 + data-element-drag-overlay="true"
  717 + className="absolute z-[50] cursor-move touch-none"
  718 + style={{ left: dragHit.x, top: dragHit.y, width: dragHit.width, height: dragHit.height }}
  719 + onPointerDown={(e) => {
  720 + if (e.button !== 0) return;
  721 + if ((e.target as HTMLElement).closest('[data-element-resize-handle="true"]')) return;
  722 + e.stopPropagation();
  723 + e.preventDefault();
  724 + onDragPointerDown(e);
  725 + }}
  726 + aria-hidden
  727 + />
  728 + ) : null}
611 <div 729 <div
612 className="pointer-events-none absolute left-0 top-0 z-[40]" 730 className="pointer-events-none absolute left-0 top-0 z-[40]"
613 style={{ width: paperWidthPx, height: paperHeightPx }} 731 style={{ width: paperWidthPx, height: paperHeightPx }}
@@ -624,43 +742,39 @@ function ElementSelectionFrame({ @@ -624,43 +742,39 @@ function ElementSelectionFrame({
624 width={w} 742 width={w}
625 height={h} 743 height={h}
626 fill="none" 744 fill="none"
627 - stroke="#111827"  
628 - strokeWidth={1}  
629 - strokeDasharray="4 3" 745 + stroke={ELEMENT_SELECTION_FRAME_STROKE}
  746 + strokeWidth={ELEMENT_SELECTION_FRAME_STROKE_WIDTH}
  747 + strokeDasharray={ELEMENT_SELECTION_FRAME_DASH}
630 /> 748 />
631 - {/* 宽度标注 */}  
632 - <line x1={x} y1={widthLineY} x2={x + w} y2={widthLineY} stroke="#111827" strokeWidth={1} />  
633 - <polygon points={dimensionArrowHead(x, widthLineY, "left")} fill="#111827" />  
634 - <polygon points={dimensionArrowHead(x + w, widthLineY, "right")} fill="#111827" /> 749 + {/* 宽度读数(无箭头,参考图 2) */}
635 <text 750 <text
636 x={x + w / 2} 751 x={x + w / 2}
637 y={widthTextY} 752 y={widthTextY}
638 textAnchor="middle" 753 textAnchor="middle"
639 - fontSize={11} 754 + fontSize={ELEMENT_DIMENSION_LABEL_FONT_SIZE}
640 fill="#111827" 755 fill="#111827"
  756 + fontWeight={600}
641 className="font-mono" 757 className="font-mono"
642 > 758 >
643 - {displayWidthLabel}  
644 - </text>  
645 - {/* 高度标注 */}  
646 - <line x1={heightLineX} y1={y} x2={heightLineX} y2={y + h} stroke="#111827" strokeWidth={1} />  
647 - <polygon points={dimensionArrowHead(heightLineX, y, "up")} fill="#111827" />  
648 - <polygon points={dimensionArrowHead(heightLineX, y + h, "down")} fill="#111827" />  
649 - <text  
650 - x={heightTextX}  
651 - y={y + h / 2}  
652 - textAnchor="middle"  
653 - fontSize={11}  
654 - fill="#111827"  
655 - className="font-mono"  
656 - transform={`rotate(-90, ${heightTextX}, ${y + h / 2})`}  
657 - >  
658 - {displayHeightLabel} 759 + {displayWidthLabel}
  760 + </text>
  761 + {/* 高度读数(无箭头,参考图 2) */}
  762 + <text
  763 + x={heightTextX}
  764 + y={y + h / 2}
  765 + textAnchor="middle"
  766 + fontSize={ELEMENT_DIMENSION_LABEL_FONT_SIZE}
  767 + fill="#111827"
  768 + fontWeight={600}
  769 + className="font-mono"
  770 + transform={`rotate(-90, ${heightTextX}, ${y + h / 2})`}
  771 + >
  772 + {displayHeightLabel}
659 </text> 773 </text>
660 </svg> 774 </svg>
661 </div> 775 </div>
662 - {interactive && onResizePointerDown  
663 - ? ELEMENT_RESIZE_HANDLES.map((handle) => { 776 + {interactive && !positionLocked && onDragPointerDown
  777 + ? resizeHandles.map((handle) => {
664 const left = x + w * handle.cx - ELEMENT_RESIZE_HANDLE_HIT / 2; 778 const left = x + w * handle.cx - ELEMENT_RESIZE_HANDLE_HIT / 2;
665 const top = y + h * handle.cy - ELEMENT_RESIZE_HANDLE_HIT / 2; 779 const top = y + h * handle.cy - ELEMENT_RESIZE_HANDLE_HIT / 2;
666 const inset = (ELEMENT_RESIZE_HANDLE_HIT - ELEMENT_RESIZE_HANDLE_SIZE) / 2; 780 const inset = (ELEMENT_RESIZE_HANDLE_HIT - ELEMENT_RESIZE_HANDLE_SIZE) / 2;
@@ -668,7 +782,7 @@ function ElementSelectionFrame({ @@ -668,7 +782,7 @@ function ElementSelectionFrame({
668 <div 782 <div
669 key={handle.id} 783 key={handle.id}
670 data-element-resize-handle="true" 784 data-element-resize-handle="true"
671 - className="absolute z-[41] touch-none" 785 + className="absolute z-[52] touch-none"
672 style={{ 786 style={{
673 left, 787 left,
674 top, 788 top,
@@ -685,12 +799,14 @@ function ElementSelectionFrame({ @@ -685,12 +799,14 @@ function ElementSelectionFrame({
685 onClick={(e) => e.stopPropagation()} 799 onClick={(e) => e.stopPropagation()}
686 > 800 >
687 <div 801 <div
688 - className="pointer-events-none absolute border border-red-700 bg-red-500 shadow-sm transition-transform hover:scale-110" 802 + className="pointer-events-none absolute transition-transform hover:scale-125"
689 style={{ 803 style={{
690 left: inset, 804 left: inset,
691 top: inset, 805 top: inset,
692 width: ELEMENT_RESIZE_HANDLE_SIZE, 806 width: ELEMENT_RESIZE_HANDLE_SIZE,
693 height: ELEMENT_RESIZE_HANDLE_SIZE, 807 height: ELEMENT_RESIZE_HANDLE_SIZE,
  808 + backgroundColor: ELEMENT_RESIZE_HANDLE_FILL,
  809 + boxShadow: ELEMENT_RESIZE_HANDLE_SHADOW,
694 }} 810 }}
695 /> 811 />
696 </div> 812 </div>
@@ -1021,39 +1137,6 @@ function formatMultipleOptionsCanvasLine( @@ -1021,39 +1137,6 @@ function formatMultipleOptionsCanvasLine(
1021 return answers || fallback; 1137 return answers || fallback;
1022 } 1138 }
1023 1139
1024 -function nutritionExtraRows(cfg: Record<string, unknown>): NutritionExtraItem[] {  
1025 - const raw = cfg.extraNutrients;  
1026 - if (!Array.isArray(raw)) return [];  
1027 - return raw.map((item, idx) => {  
1028 - const row = item as Record<string, unknown>;  
1029 - return {  
1030 - id: String(row.id ?? `extra-${idx}`),  
1031 - name: String(row.name ?? ''),  
1032 - value: String(row.value ?? ''),  
1033 - unit: String(row.unit ?? ''),  
1034 - };  
1035 - });  
1036 -}  
1037 -  
1038 -function nutritionFixedField(  
1039 - cfg: Record<string, unknown>,  
1040 - key: string,  
1041 - field: 'value' | 'unit',  
1042 -): string {  
1043 - const fixedRows = Array.isArray(cfg.fixedNutrients)  
1044 - ? (cfg.fixedNutrients as Record<string, unknown>[])  
1045 - : [];  
1046 - const row = fixedRows.find((item) => String(item.key ?? '').trim() === key);  
1047 - if (row) {  
1048 - const fromRow = String(row[field] ?? '').trim();  
1049 - if (fromRow !== '') return fromRow;  
1050 - }  
1051 - const directKey = field === 'value' ? key : `${key}Unit`;  
1052 - const direct = cfg[directKey];  
1053 - if (direct != null && String(direct).trim() !== '') return String(direct).trim();  
1054 - return String(row?.[field] ?? '').trim();  
1055 -}  
1056 -  
1057 function formatDateByPreset(format: string, date: Date): string { 1140 function formatDateByPreset(format: string, date: Date): string {
1058 const yyyy = String(date.getFullYear()); 1141 const yyyy = String(date.getFullYear());
1059 const yy = yyyy.slice(-2); 1142 const yy = yyyy.slice(-2);
@@ -1125,6 +1208,90 @@ function normalizeWeightUnit(raw: unknown): &#39;lb&#39; | &#39;kg&#39; | &#39;mg&#39; | &#39;g&#39; | &#39;oz&#39; { @@ -1125,6 +1208,90 @@ function normalizeWeightUnit(raw: unknown): &#39;lb&#39; | &#39;kg&#39; | &#39;mg&#39; | &#39;g&#39; | &#39;oz&#39; {
1125 return 'g'; 1208 return 'g';
1126 } 1209 }
1127 1210
  1211 +/** 竖排旋转:内层先按「高×宽」排版,再绕中心 -90°,与打印/预览 canvas 一致 */
  1212 +function VerticalRotationFrame({
  1213 + children,
  1214 + className,
  1215 + boxWidth,
  1216 + boxHeight,
  1217 +}: {
  1218 + children: React.ReactNode;
  1219 + className?: string;
  1220 + boxWidth: number;
  1221 + boxHeight: number;
  1222 +}) {
  1223 + const innerW = Math.max(1, boxHeight);
  1224 + const innerH = Math.max(1, boxWidth);
  1225 + return (
  1226 + <div className={cn('relative flex h-full w-full items-center justify-center overflow-visible', className)}>
  1227 + <div
  1228 + className="flex shrink-0 items-center justify-center overflow-visible"
  1229 + style={{
  1230 + width: innerW,
  1231 + height: innerH,
  1232 + transform: 'rotate(-90deg)',
  1233 + transformOrigin: 'center center',
  1234 + }}
  1235 + >
  1236 + <div className="h-full w-full overflow-visible">{children}</div>
  1237 + </div>
  1238 + </div>
  1239 + );
  1240 +}
  1241 +
  1242 +/** 文本类控件:按 config 水平/垂直对齐在元素框内排版 */
  1243 +function AlignedTextBox({
  1244 + cfg,
  1245 + commonStyle,
  1246 + className,
  1247 + children,
  1248 + row = false,
  1249 + allowOverflow = false,
  1250 +}: {
  1251 + cfg: Record<string, unknown>;
  1252 + commonStyle: React.CSSProperties;
  1253 + className?: string;
  1254 + children: React.ReactNode;
  1255 + row?: boolean;
  1256 + allowOverflow?: boolean;
  1257 +}) {
  1258 + const verticalAlign = readVerticalAlign(cfg);
  1259 + const textAlign = String(commonStyle.textAlign ?? 'left');
  1260 + const flexStyle: React.CSSProperties = row
  1261 + ? {
  1262 + display: 'flex',
  1263 + flexDirection: 'row',
  1264 + justifyContent: textAlignToFlexAlign(textAlign),
  1265 + alignItems: verticalAlignToFlexJustify(verticalAlign),
  1266 + }
  1267 + : {
  1268 + display: 'flex',
  1269 + flexDirection: 'column',
  1270 + justifyContent: verticalAlignToFlexJustify(verticalAlign),
  1271 + alignItems: textAlignToFlexAlign(textAlign),
  1272 + };
  1273 + return (
  1274 + <div
  1275 + className={cn(
  1276 + 'w-full h-full px-1',
  1277 + allowOverflow ? 'overflow-visible' : 'overflow-hidden',
  1278 + className,
  1279 + )}
  1280 + style={flexStyle}
  1281 + >
  1282 + <div
  1283 + className={cn('min-w-0 w-full max-w-full', allowOverflow && 'whitespace-nowrap overflow-visible')}
  1284 + style={{
  1285 + ...commonStyle,
  1286 + textAlign: textAlign as React.CSSProperties['textAlign'],
  1287 + }}
  1288 + >
  1289 + {children}
  1290 + </div>
  1291 + </div>
  1292 + );
  1293 +}
  1294 +
1128 /** 根据元素类型与 config 渲染画布上的默认内容 */ 1295 /** 根据元素类型与 config 渲染画布上的默认内容 */
1129 function ElementContent({ 1296 function ElementContent({
1130 el, 1297 el,
@@ -1141,15 +1308,31 @@ function ElementContent({ @@ -1141,15 +1308,31 @@ function ElementContent({
1141 }) { 1308 }) {
1142 const cfg = el.config as Record<string, unknown>; 1309 const cfg = el.config as Record<string, unknown>;
1143 const type = canonicalElementType(el.type); 1310 const type = canonicalElementType(el.type);
1144 - const isVerticalRotation = el.rotation === 'vertical';  
1145 - const inverted = readInvertColors(cfg); 1311 + const isVerticalRotation = readElementRotation(el) === 'vertical';
  1312 +
  1313 + const wrapVertical = (node: React.ReactNode, className?: string) =>
  1314 + isVerticalRotation ? (
  1315 + <VerticalRotationFrame
  1316 + className={className}
  1317 + boxWidth={el.width}
  1318 + boxHeight={el.height}
  1319 + >
  1320 + {node}
  1321 + </VerticalRotationFrame>
  1322 + ) : (
  1323 + node
  1324 + );
  1325 + const textOverflow = isVerticalRotation;
1146 1326
1147 - // Common styles 1327 + const inverted = readInvertColors(cfg);
1148 const resolvedFontFamily = resolveLabelEditorElementFontFamily(cfg); 1328 const resolvedFontFamily = resolveLabelEditorElementFontFamily(cfg);
  1329 +
1149 const commonStyle: React.CSSProperties = { 1330 const commonStyle: React.CSSProperties = {
1150 fontSize: (cfg?.fontSize as number) ?? 14, 1331 fontSize: (cfg?.fontSize as number) ?? 14,
1151 fontFamily: resolvedFontFamily, 1332 fontFamily: resolvedFontFamily,
1152 - fontWeight: (cfg?.fontWeight as string) ?? 'normal', 1333 + fontWeight: readFontWeight(cfg),
  1334 + fontStyle: readFontStyle(cfg),
  1335 + textDecoration: readTextDecoration(cfg),
1153 textAlign: (cfg?.textAlign as any) ?? 'left', 1336 textAlign: (cfg?.textAlign as any) ?? 'left',
1154 color: inverted ? INVERT_COLORS_FG : ((cfg?.color as string) ?? '#000'), 1337 color: inverted ? INVERT_COLORS_FG : ((cfg?.color as string) ?? '#000'),
1155 backgroundColor: inverted ? INVERT_COLORS_BG : undefined, 1338 backgroundColor: inverted ? INVERT_COLORS_BG : undefined,
@@ -1160,31 +1343,19 @@ function ElementContent({ @@ -1160,31 +1343,19 @@ function ElementContent({
1160 ? 'border-gray-300 bg-transparent' 1343 ? 'border-gray-300 bg-transparent'
1161 : 'border-gray-300 bg-white'; 1344 : 'border-gray-300 bg-white';
1162 1345
1163 - // Rotation support:  
1164 - // The editor's Rotation is currently a simple horizontal/vertical toggle.  
1165 - // For text-like elements we render vertical via writing-mode to avoid layout clipping.  
1166 - const textLike =  
1167 - type === 'TEXT_STATIC' || type === 'TEXT_PRODUCT' || type === 'TEXT_PRICE';  
1168 - const textRotationStyle: React.CSSProperties =  
1169 - isVerticalRotation && textLike  
1170 - ? { writingMode: 'vertical-rl', textOrientation: 'mixed' as any }  
1171 - : {};  
1172 - const rotateBoxStyle: React.CSSProperties = isVerticalRotation  
1173 - ? { transform: 'rotate(-90deg)', transformOrigin: 'center center' }  
1174 - : {};  
1175 -  
1176 // 文本类 1346 // 文本类
1177 const inputType = cfg?.inputType as string | undefined; 1347 const inputType = cfg?.inputType as string | undefined;
1178 if (type === 'TEXT_STATIC' && isCompanyAutoElement(el) && !isAppPrintField) { 1348 if (type === 'TEXT_STATIC' && isCompanyAutoElement(el) && !isAppPrintField) {
1179 const previewText = formatCompanyPrintPreviewText(cfg); 1349 const previewText = formatCompanyPrintPreviewText(cfg);
1180 - return (  
1181 - <div  
1182 - className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-words leading-tight italic text-gray-600"  
1183 - style={{ ...commonStyle, ...textRotationStyle }}  
1184 - title="Filled from company profile when printing" 1350 + return wrapVertical(
  1351 + <AlignedTextBox
  1352 + cfg={cfg}
  1353 + commonStyle={commonStyle}
  1354 + allowOverflow={textOverflow}
  1355 + className="whitespace-pre-wrap break-words leading-tight italic text-gray-600"
1185 > 1356 >
1186 {previewText} 1357 {previewText}
1187 - </div> 1358 + </AlignedTextBox>,
1188 ); 1359 );
1189 } 1360 }
1190 if (type === 'TEXT_STATIC') { 1361 if (type === 'TEXT_STATIC') {
@@ -1196,29 +1367,32 @@ function ElementContent({ @@ -1196,29 +1367,32 @@ function ElementContent({
1196 : []; 1367 : [];
1197 const line = formatMultipleOptionsCanvasLine(cfg, text, selected); 1368 const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
1198 const muted = selected.length === 0; 1369 const muted = selected.length === 0;
1199 - return (  
1200 - <div 1370 + return wrapVertical(
  1371 + <AlignedTextBox
  1372 + cfg={cfg}
  1373 + commonStyle={commonStyle}
  1374 + allowOverflow={textOverflow}
1201 className={cn( 1375 className={cn(
1202 - 'w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-words leading-tight', 1376 + 'whitespace-pre-wrap break-words leading-tight',
1203 muted && !inverted && 'text-gray-400', 1377 muted && !inverted && 'text-gray-400',
1204 muted && inverted && 'text-gray-300', 1378 muted && inverted && 'text-gray-300',
1205 )} 1379 )}
1206 - style={{ ...commonStyle, ...textRotationStyle }}  
1207 - title={line}  
1208 > 1380 >
1209 {line} 1381 {line}
1210 - </div> 1382 + </AlignedTextBox>,
1211 ); 1383 );
1212 } 1384 }
1213 const display = 1385 const display =
1214 inputType === 'number' ? ((cfg?.text as string) ?? '0') : text; 1386 inputType === 'number' ? ((cfg?.text as string) ?? '0') : text;
1215 - return (  
1216 - <div  
1217 - className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-words leading-tight"  
1218 - style={{ ...commonStyle, ...textRotationStyle }} 1387 + return wrapVertical(
  1388 + <AlignedTextBox
  1389 + cfg={cfg}
  1390 + commonStyle={commonStyle}
  1391 + allowOverflow={textOverflow}
  1392 + className="whitespace-pre-wrap break-words leading-tight"
1219 > 1393 >
1220 {display} 1394 {display}
1221 - </div> 1395 + </AlignedTextBox>,
1222 ); 1396 );
1223 } 1397 }
1224 if (isAppPrintField) { 1398 if (isAppPrintField) {
@@ -1227,33 +1401,33 @@ function ElementContent({ @@ -1227,33 +1401,33 @@ function ElementContent({
1227 ? (cfg.selectedOptionValues as string[]) 1401 ? (cfg.selectedOptionValues as string[])
1228 : []; 1402 : [];
1229 const line = formatMultipleOptionsCanvasLine(cfg, text, selected); 1403 const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
1230 - return ( 1404 + return wrapVertical(
1231 <div 1405 <div
1232 className="w-full h-full px-1 flex flex-col justify-center overflow-hidden pointer-events-none italic text-[11px] leading-tight break-all" 1406 className="w-full h-full px-1 flex flex-col justify-center overflow-hidden pointer-events-none italic text-[11px] leading-tight break-all"
1233 - style={{ ...commonStyle, ...textRotationStyle }} 1407 + style={{ ...commonStyle }}
1234 title="Filled in mobile app when printing" 1408 title="Filled in mobile app when printing"
1235 > 1409 >
1236 {line} 1410 {line}
1237 - </div> 1411 + </div>,
1238 ); 1412 );
1239 } 1413 }
1240 const display = 1414 const display =
1241 inputType === 'number' ? ((cfg?.text as string) ?? '0') : text; 1415 inputType === 'number' ? ((cfg?.text as string) ?? '0') : text;
1242 - return ( 1416 + return wrapVertical(
1243 <div 1417 <div
1244 className={cn( 1418 className={cn(
1245 'w-full h-full px-1 flex items-center overflow-hidden pointer-events-none italic text-[11px]', 1419 'w-full h-full px-1 flex items-center overflow-hidden pointer-events-none italic text-[11px]',
1246 !inverted && 'text-gray-600', 1420 !inverted && 'text-gray-600',
1247 )} 1421 )}
1248 - style={{ ...commonStyle, ...textRotationStyle }} 1422 + style={{ ...commonStyle }}
1249 title="Filled in mobile app when printing" 1423 title="Filled in mobile app when printing"
1250 > 1424 >
1251 {display} 1425 {display}
1252 - </div> 1426 + </div>,
1253 ); 1427 );
1254 } 1428 }
1255 if (inputType === 'number') { 1429 if (inputType === 'number') {
1256 - return ( 1430 + return wrapVertical(
1257 <input 1431 <input
1258 type="number" 1432 type="number"
1259 readOnly 1433 readOnly
@@ -1262,8 +1436,8 @@ function ElementContent({ @@ -1262,8 +1436,8 @@ function ElementContent({
1262 'w-full h-full min-w-0 border rounded px-1 pointer-events-none', 1436 'w-full h-full min-w-0 border rounded px-1 pointer-events-none',
1263 invertedInputClass, 1437 invertedInputClass,
1264 )} 1438 )}
1265 - style={{ ...commonStyle, ...textRotationStyle, textAlign: 'right' }}  
1266 - /> 1439 + style={{ ...commonStyle, textAlign: 'right' }}
  1440 + />,
1267 ); 1441 );
1268 } 1442 }
1269 if (inputType === 'options') { 1443 if (inputType === 'options') {
@@ -1273,22 +1447,23 @@ function ElementContent({ @@ -1273,22 +1447,23 @@ function ElementContent({
1273 : []; 1447 : [];
1274 const line = formatMultipleOptionsCanvasLine(cfg, text, selected); 1448 const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
1275 const muted = selected.length === 0; 1449 const muted = selected.length === 0;
1276 - return (  
1277 - <div 1450 + return wrapVertical(
  1451 + <AlignedTextBox
  1452 + cfg={cfg}
  1453 + commonStyle={commonStyle}
  1454 + allowOverflow={textOverflow}
1278 className={cn( 1455 className={cn(
1279 - 'w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight', 1456 + 'whitespace-pre-wrap break-all leading-tight',
1280 muted && !inverted && 'text-gray-400', 1457 muted && !inverted && 'text-gray-400',
1281 muted && inverted && 'text-gray-300', 1458 muted && inverted && 'text-gray-300',
1282 )} 1459 )}
1283 - style={{ ...commonStyle, ...textRotationStyle }}  
1284 - title={line}  
1285 > 1460 >
1286 {line} 1461 {line}
1287 - </div> 1462 + </AlignedTextBox>,
1288 ); 1463 );
1289 } 1464 }
1290 if (inputType === 'text') { 1465 if (inputType === 'text') {
1291 - return ( 1466 + return wrapVertical(
1292 <input 1467 <input
1293 type="text" 1468 type="text"
1294 readOnly 1469 readOnly
@@ -1297,48 +1472,38 @@ function ElementContent({ @@ -1297,48 +1472,38 @@ function ElementContent({
1297 'w-full h-full min-w-0 border rounded px-1 pointer-events-none', 1472 'w-full h-full min-w-0 border rounded px-1 pointer-events-none',
1298 invertedInputClass, 1473 invertedInputClass,
1299 )} 1474 )}
1300 - style={{ ...commonStyle, ...textRotationStyle }}  
1301 - /> 1475 + style={{ ...commonStyle }}
  1476 + />,
1302 ); 1477 );
1303 } 1478 }
1304 - return (  
1305 - <div  
1306 - className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight"  
1307 - style={{ ...commonStyle, ...textRotationStyle }}  
1308 - > 1479 + return wrapVertical(
  1480 + <AlignedTextBox cfg={cfg} commonStyle={commonStyle} allowOverflow={textOverflow} className="break-all leading-tight">
1309 {text} 1481 {text}
1310 - </div> 1482 + </AlignedTextBox>,
1311 ); 1483 );
1312 } 1484 }
1313 if (type === 'TEXT_PRODUCT') { 1485 if (type === 'TEXT_PRODUCT') {
1314 const text = (cfg?.text as string) ?? 'Product name'; 1486 const text = (cfg?.text as string) ?? 'Product name';
1315 - return (  
1316 - <div  
1317 - className="w-full h-full px-1 overflow-hidden whitespace-pre-wrap break-all leading-tight"  
1318 - style={{ ...commonStyle, ...textRotationStyle }} 1487 + return wrapVertical(
  1488 + <AlignedTextBox
  1489 + cfg={cfg}
  1490 + commonStyle={commonStyle}
  1491 + allowOverflow={textOverflow}
  1492 + className={cn(
  1493 + textOverflow ? 'whitespace-nowrap' : 'whitespace-pre-wrap break-all',
  1494 + 'leading-tight',
  1495 + )}
1319 > 1496 >
1320 {text} 1497 {text}
1321 - </div> 1498 + </AlignedTextBox>,
1322 ); 1499 );
1323 } 1500 }
1324 if (type === 'TEXT_PRICE') { 1501 if (type === 'TEXT_PRICE') {
1325 const text = (cfg?.text as string) ?? '0.00'; 1502 const text = (cfg?.text as string) ?? '0.00';
1326 - return (  
1327 - <div  
1328 - className="w-full h-full px-1 overflow-hidden flex items-center"  
1329 - style={{  
1330 - ...commonStyle,  
1331 - ...textRotationStyle,  
1332 - justifyContent:  
1333 - commonStyle.textAlign === 'center'  
1334 - ? 'center'  
1335 - : commonStyle.textAlign === 'right'  
1336 - ? 'flex-end'  
1337 - : 'flex-start',  
1338 - }}  
1339 - > 1503 + return wrapVertical(
  1504 + <AlignedTextBox cfg={cfg} commonStyle={commonStyle} allowOverflow={textOverflow} row>
1340 <span>{text}</span> 1505 <span>{text}</span>
1341 - </div> 1506 + </AlignedTextBox>,
1342 ); 1507 );
1343 } 1508 }
1344 1509
@@ -1347,7 +1512,7 @@ function ElementContent({ @@ -1347,7 +1512,7 @@ function ElementContent({
1347 const data = (cfg?.data as string) ?? '123456789'; 1512 const data = (cfg?.data as string) ?? '123456789';
1348 const showText = (cfg?.showText as boolean) !== false; 1513 const showText = (cfg?.showText as boolean) !== false;
1349 const orientation = ( 1514 const orientation = (
1350 - el.rotation === 'vertical' || (cfg?.orientation as string) === 'vertical' 1515 + readElementRotation(el) === 'vertical' || (cfg?.orientation as string) === 'vertical'
1351 ? 'vertical' 1516 ? 'vertical'
1352 : 'horizontal' 1517 : 'horizontal'
1353 ) as 'horizontal' | 'vertical'; 1518 ) as 'horizontal' | 'vertical';
@@ -1382,31 +1547,29 @@ function ElementContent({ @@ -1382,31 +1547,29 @@ function ElementContent({
1382 ); 1547 );
1383 } 1548 }
1384 1549
1385 - // 图片/Logo 1550 + // Image / Logo
1386 if (type === 'IMAGE') { 1551 if (type === 'IMAGE') {
1387 const src = cfg?.src as string | undefined; 1552 const src = cfg?.src as string | undefined;
1388 - const imageRotateStyle: React.CSSProperties = isVerticalRotation  
1389 - ? { transform: 'rotate(-90deg)' }  
1390 - : {}; 1553 + const scaleMode = readImageScaleMode(cfg);
  1554 + const imgClass = imageScaleModeImgClassName(scaleMode);
  1555 + const parsedPalette = parseElementPaletteContext(el);
  1556 + const placeholderLabel =
  1557 + parsedPalette?.paletteLabel ?? inferPaletteEnglishLabel(el);
1391 if (src) { 1558 if (src) {
1392 - return ( 1559 + return wrapVertical(
1393 <div className="w-full h-full flex items-center justify-center overflow-hidden"> 1560 <div className="w-full h-full flex items-center justify-center overflow-hidden">
1394 <img 1561 <img
1395 src={resolvePictureUrlForDisplay(src)} 1562 src={resolvePictureUrlForDisplay(src)}
1396 alt="" 1563 alt=""
1397 - className="max-w-full max-h-full object-contain"  
1398 - style={imageRotateStyle} 1564 + className={imgClass}
1399 /> 1565 />
1400 - </div> 1566 + </div>,
1401 ); 1567 );
1402 } 1568 }
1403 - return (  
1404 - <div  
1405 - className="w-full h-full flex flex-col items-center justify-center bg-gray-100 text-gray-500 text-[10px] border border-dashed border-gray-300"  
1406 - style={imageRotateStyle}  
1407 - >  
1408 - <span className="font-medium">Logo</span>  
1409 - </div> 1569 + return wrapVertical(
  1570 + <div className="w-full h-full flex flex-col items-center justify-center bg-gray-100 text-gray-500 text-[10px] border border-dashed border-gray-300">
  1571 + <span className="font-medium">{placeholderLabel}</span>
  1572 + </div>,
1410 ); 1573 );
1411 } 1574 }
1412 1575
@@ -1415,12 +1578,14 @@ function ElementContent({ @@ -1415,12 +1578,14 @@ function ElementContent({
1415 const previewFmtRaw = cfg?.__previewFormatted; 1578 const previewFmtRaw = cfg?.__previewFormatted;
1416 if (typeof previewFmtRaw === 'string') { 1579 if (typeof previewFmtRaw === 'string') {
1417 const previewFmt = previewFmtRaw.trim(); 1580 const previewFmt = previewFmtRaw.trim();
1418 - return (  
1419 - <div className="w-full h-full flex items-center justify-center overflow-hidden">  
1420 - <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>  
1421 - {previewFmt || '—'}  
1422 - </div>  
1423 - </div> 1581 + return wrapVertical(
  1582 + <AlignedTextBox
  1583 + cfg={cfg}
  1584 + commonStyle={commonStyle}
  1585 + className="whitespace-nowrap"
  1586 + >
  1587 + {previewFmt || '—'}
  1588 + </AlignedTextBox>,
1424 ); 1589 );
1425 } 1590 }
1426 const it = String(cfg?.inputType ?? cfg?.InputType ?? '').toLowerCase(); 1591 const it = String(cfg?.inputType ?? cfg?.InputType ?? '').toLowerCase();
@@ -1448,7 +1613,7 @@ function ElementContent({ @@ -1448,7 +1613,7 @@ function ElementContent({
1448 <div className="w-full h-full flex items-center justify-center overflow-hidden"> 1613 <div className="w-full h-full flex items-center justify-center overflow-hidden">
1449 <div 1614 <div
1450 className="px-1 flex items-center justify-center overflow-hidden pointer-events-none text-[10px] text-center whitespace-nowrap" 1615 className="px-1 flex items-center justify-center overflow-hidden pointer-events-none text-[10px] text-center whitespace-nowrap"
1451 - style={{ ...commonStyle, ...rotateBoxStyle }} 1616 + style={{ ...commonStyle }}
1452 title={`Format: ${format}`} 1617 title={`Format: ${format}`}
1453 > 1618 >
1454 {format} 1619 {format}
@@ -1463,17 +1628,15 @@ function ElementContent({ @@ -1463,17 +1628,15 @@ function ElementContent({
1463 readOnly 1628 readOnly
1464 value="2025-02-01" 1629 value="2025-02-01"
1465 className="w-full h-full min-w-0 border border-gray-300 bg-white rounded px-1 pointer-events-none text-[10px]" 1630 className="w-full h-full min-w-0 border border-gray-300 bg-white rounded px-1 pointer-events-none text-[10px]"
1466 - style={{ ...commonStyle, ...rotateBoxStyle }} 1631 + style={{ ...commonStyle }}
1467 /> 1632 />
1468 </div> 1633 </div>
1469 ); 1634 );
1470 } 1635 }
1471 - return (  
1472 - <div className="w-full h-full flex items-center justify-center overflow-hidden">  
1473 - <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>  
1474 - {example}  
1475 - </div>  
1476 - </div> 1636 + return wrapVertical(
  1637 + <AlignedTextBox cfg={cfg} commonStyle={commonStyle} className="whitespace-nowrap">
  1638 + {example}
  1639 + </AlignedTextBox>,
1477 ); 1640 );
1478 } 1641 }
1479 1642
@@ -1481,34 +1644,36 @@ function ElementContent({ @@ -1481,34 +1644,36 @@ function ElementContent({
1481 if (type === 'TIME') { 1644 if (type === 'TIME') {
1482 const previewTime = cfg?.__previewFormatted; 1645 const previewTime = cfg?.__previewFormatted;
1483 if (typeof previewTime === 'string') { 1646 if (typeof previewTime === 'string') {
1484 - return (  
1485 - <div className="w-full h-full flex items-center justify-center overflow-hidden">  
1486 - <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>  
1487 - {previewTime.trim() || '—'}  
1488 - </div>  
1489 - </div> 1647 + return wrapVertical(
  1648 + <AlignedTextBox
  1649 + cfg={cfg}
  1650 + commonStyle={commonStyle}
  1651 + className="whitespace-nowrap"
  1652 + >
  1653 + {previewTime.trim() || '—'}
  1654 + </AlignedTextBox>,
1490 ); 1655 );
1491 } 1656 }
1492 const d = new Date(); 1657 const d = new Date();
1493 const example = formatDateByPreset('HH:mm', d); 1658 const example = formatDateByPreset('HH:mm', d);
1494 - return (  
1495 - <div className="w-full h-full flex items-center justify-center overflow-hidden">  
1496 - <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>  
1497 - {example}  
1498 - </div>  
1499 - </div> 1659 + return wrapVertical(
  1660 + <AlignedTextBox cfg={cfg} commonStyle={commonStyle} className="whitespace-nowrap">
  1661 + {example}
  1662 + </AlignedTextBox>,
1500 ); 1663 );
1501 } 1664 }
1502 1665
1503 if (type === 'DURATION') { 1666 if (type === 'DURATION') {
1504 const previewDur = cfg?.__previewFormatted; 1667 const previewDur = cfg?.__previewFormatted;
1505 if (typeof previewDur === 'string') { 1668 if (typeof previewDur === 'string') {
1506 - return (  
1507 - <div className="w-full h-full flex items-center justify-center overflow-hidden">  
1508 - <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>  
1509 - {previewDur.trim() || '—'}  
1510 - </div>  
1511 - </div> 1669 + return wrapVertical(
  1670 + <AlignedTextBox
  1671 + cfg={cfg}
  1672 + commonStyle={commonStyle}
  1673 + className="whitespace-nowrap"
  1674 + >
  1675 + {previewDur.trim() || '—'}
  1676 + </AlignedTextBox>,
1512 ); 1677 );
1513 } 1678 }
1514 const rawFormat = 1679 const rawFormat =
@@ -1521,12 +1686,10 @@ function ElementContent({ @@ -1521,12 +1686,10 @@ function ElementContent({
1521 const rawV = cfg?.durationValue ?? cfg?.value ?? cfg?.offsetDays ?? cfg?.DurationValue ?? cfg?.Value ?? cfg?.OffsetDays; 1686 const rawV = cfg?.durationValue ?? cfg?.value ?? cfg?.offsetDays ?? cfg?.DurationValue ?? cfg?.Value ?? cfg?.OffsetDays;
1522 const durationValue = Number.isFinite(Number(rawV)) ? Number(rawV) : 3; 1687 const durationValue = Number.isFinite(Number(rawV)) ? Number(rawV) : 3;
1523 const example = `${durationValue} ${unit}`; 1688 const example = `${durationValue} ${unit}`;
1524 - return (  
1525 - <div className="w-full h-full flex items-center justify-center overflow-hidden">  
1526 - <div className="px-1 overflow-hidden whitespace-nowrap" style={{ ...commonStyle, ...rotateBoxStyle }}>  
1527 - {example}  
1528 - </div>  
1529 - </div> 1689 + return wrapVertical(
  1690 + <AlignedTextBox cfg={cfg} commonStyle={commonStyle} className="whitespace-nowrap">
  1691 + {example}
  1692 + </AlignedTextBox>,
1530 ); 1693 );
1531 } 1694 }
1532 1695
@@ -1551,16 +1714,14 @@ function ElementContent({ @@ -1551,16 +1714,14 @@ function ElementContent({
1551 const weightTextAlignRaw = String(cfg?.textAlign ?? cfg?.TextAlign ?? 'left').toLowerCase(); 1714 const weightTextAlignRaw = String(cfg?.textAlign ?? cfg?.TextAlign ?? 'left').toLowerCase();
1552 const weightTextAlign: 'left' | 'center' | 'right' = 1715 const weightTextAlign: 'left' | 'center' | 'right' =
1553 weightTextAlignRaw === 'center' || weightTextAlignRaw === 'right' ? weightTextAlignRaw : 'left'; 1716 weightTextAlignRaw === 'center' || weightTextAlignRaw === 'right' ? weightTextAlignRaw : 'left';
1554 - return (  
1555 - <div className="w-full h-full flex items-center justify-center overflow-hidden">  
1556 - <div  
1557 - className="px-1 overflow-hidden whitespace-nowrap"  
1558 - style={{ ...commonStyle, ...rotateBoxStyle, fontSize: weightFontSize, textAlign: weightTextAlign }}  
1559 - >  
1560 - {weightNum}  
1561 - {weightUnit}  
1562 - </div>  
1563 - </div> 1717 + return wrapVertical(
  1718 + <AlignedTextBox
  1719 + cfg={cfg}
  1720 + commonStyle={{ ...commonStyle, fontSize: weightFontSize, textAlign: weightTextAlign }}
  1721 + className="whitespace-nowrap"
  1722 + >
  1723 + {formatWeightDisplay(String(weightNum), weightUnit)}
  1724 + </AlignedTextBox>,
1564 ); 1725 );
1565 } 1726 }
1566 1727
@@ -1571,68 +1732,10 @@ function ElementContent({ @@ -1571,68 +1732,10 @@ function ElementContent({
1571 return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>{currency}{(unitPrice * weight).toFixed(2)}</div>; 1732 return <div className="w-full h-full px-1 overflow-hidden whitespace-nowrap" style={commonStyle}>{currency}{(unitPrice * weight).toFixed(2)}</div>;
1572 } 1733 }
1573 1734
1574 - // 营养成分表 1735 + // 营养成分表(图2标准布局)
1575 if (type === 'NUTRITION') { 1736 if (type === 'NUTRITION') {
1576 - const servingsPerContainer = String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? '').trim();  
1577 - const servingSize = String(cfg.servingSize ?? cfg.ServingSize ?? '').trim();  
1578 - const calories = String(cfg.calories ?? cfg.Calories ?? nutritionFixedField(cfg, 'calories', 'value') ?? '').trim();  
1579 - const nutritionTitleSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16;  
1580 - const baseRows = NUTRITION_FIXED_ITEMS.map((item) => {  
1581 - const value = nutritionFixedField(cfg, item.key, 'value');  
1582 - const unit = nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '');  
1583 - return {  
1584 - id: item.key,  
1585 - label: item.label,  
1586 - value,  
1587 - unit,  
1588 - };  
1589 - });  
1590 - const extraRows = nutritionExtraRows(cfg).map((item) => ({  
1591 - id: item.id,  
1592 - label: item.name.trim() || 'Other',  
1593 - value: item.value.trim(),  
1594 - unit: item.unit.trim(),  
1595 - }));  
1596 - const rows = [...baseRows, ...extraRows];  
1597 - const formatNutritionValue = (value: string, unit: string): string => {  
1598 - const v = String(value ?? '').trim();  
1599 - const u = String(unit ?? '').trim();  
1600 - if (!v && !u) return '';  
1601 - return `<${v}${u ? ` ${u}` : ''}`;  
1602 - };  
1603 const nutritionContent = ( 1737 const nutritionContent = (
1604 - <div  
1605 - className="text-[10px] p-1 w-full h-full overflow-hidden flex flex-col leading-tight bg-white"  
1606 - style={{ fontFamily: resolvedFontFamily }}  
1607 - >  
1608 - <div className="font-bold border-b border-black pb-0.5" style={{ fontSize: `${nutritionTitleSize}px` }}>  
1609 - Nutrition Facts  
1610 - </div>  
1611 - <div className="flex items-center justify-between py-0.5 mt-0.5 border-b border-black">  
1612 - <span className="font-semibold text-[10px]">Calories</span>  
1613 - <span className="font-semibold text-[10px]">  
1614 - {calories ? formatNutritionValue(calories, '') : ''}  
1615 - </span>  
1616 - </div>  
1617 - <div className="flex items-center justify-between py-0.5 text-[10px]">  
1618 - <span>Servings Per Container</span>  
1619 - <span>{servingsPerContainer}</span>  
1620 - </div>  
1621 - <div className="flex items-center justify-between pb-0.5 text-[10px] border-b border-black">  
1622 - <span>Serving Size</span>  
1623 - <span>{servingSize}</span>  
1624 - </div>  
1625 - <div className="flex-1 min-h-0 overflow-hidden pt-0.5">  
1626 - {rows.map((row) => (  
1627 - <div key={row.id} className="flex items-center justify-between py-[1px] text-[10px]">  
1628 - <span className="truncate font-medium">{row.label}</span>  
1629 - <span className="shrink-0 font-medium">  
1630 - {formatNutritionValue(row.value, row.unit)}  
1631 - </span>  
1632 - </div>  
1633 - ))}  
1634 - </div>  
1635 - </div> 1738 + <NutritionFactsPanel cfg={cfg} fontFamily={resolvedFontFamily} />
1636 ); 1739 );
1637 return ( 1740 return (
1638 <div className="w-full h-full flex items-center justify-center overflow-hidden"> 1741 <div className="w-full h-full flex items-center justify-center overflow-hidden">
@@ -1655,7 +1758,6 @@ function ElementContent({ @@ -1655,7 +1758,6 @@ function ElementContent({
1655 ); 1758 );
1656 } 1759 }
1657 1760
1658 - // 空白占位:预印刷 Logo/Image 区域,预览区展示占位字样  
1659 if (type === 'BLANK') { 1761 if (type === 'BLANK') {
1660 const fontSize = Math.max(11, Math.min(el.width * 0.2, el.height * 0.36, 56)); 1762 const fontSize = Math.max(11, Math.min(el.width * 0.2, el.height * 0.36, 56));
1661 return ( 1763 return (
@@ -1770,6 +1872,12 @@ export function LabelCanvas({ @@ -1770,6 +1872,12 @@ export function LabelCanvas({
1770 move: (e: PointerEvent) => void; 1872 move: (e: PointerEvent) => void;
1771 up: (e: PointerEvent) => void; 1873 up: (e: PointerEvent) => void;
1772 } | null>(null); 1874 } | null>(null);
  1875 + const dragDocumentListenersRef = useRef<{
  1876 + move: (e: PointerEvent) => void;
  1877 + up: (e: PointerEvent) => void;
  1878 + } | null>(null);
  1879 + const liveElementPatchRef = useRef<LabelElementLivePatch | null>(null);
  1880 + liveElementPatchRef.current = liveElementPatch;
1773 const paperResizeRef = useRef<{ 1881 const paperResizeRef = useRef<{
1774 edge: PaperResizeEdge; 1882 edge: PaperResizeEdge;
1775 startX: number; 1883 startX: number;
@@ -1784,6 +1892,7 @@ export function LabelCanvas({ @@ -1784,6 +1892,7 @@ export function LabelCanvas({
1784 const [isSpacePressed, setIsSpacePressed] = React.useState(false); 1892 const [isSpacePressed, setIsSpacePressed] = React.useState(false);
1785 const [isPanning, setIsPanning] = React.useState(false); 1893 const [isPanning, setIsPanning] = React.useState(false);
1786 const [paperResizeCursor, setPaperResizeCursor] = React.useState<string | null>(null); 1894 const [paperResizeCursor, setPaperResizeCursor] = React.useState<string | null>(null);
  1895 + const [availableElementsOpen, setAvailableElementsOpen] = React.useState(false);
1787 const panStartRef = useRef<{ x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null); 1896 const panStartRef = useRef<{ x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
1788 const [panOffset, setPanOffset] = React.useState({ x: 0, y: 0 }); 1897 const [panOffset, setPanOffset] = React.useState({ x: 0, y: 0 });
1789 const panOffsetStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null); 1898 const panOffsetStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
@@ -1814,12 +1923,7 @@ export function LabelCanvas({ @@ -1814,12 +1923,7 @@ export function LabelCanvas({
1814 const showGrid = template.showGrid !== false; 1923 const showGrid = template.showGrid !== false;
1815 const isRotatedPrint = printOrientation === 'horizontal'; 1924 const isRotatedPrint = printOrientation === 'horizontal';
1816 const effectiveCanvasBorder = canvasBorder ?? template.border ?? 'none'; 1925 const effectiveCanvasBorder = canvasBorder ?? template.border ?? 'none';
1817 - const canvasBorderClass =  
1818 - effectiveCanvasBorder === 'line'  
1819 - ? 'border border-gray-500'  
1820 - : effectiveCanvasBorder === 'dotted'  
1821 - ? 'border border-dotted border-gray-500'  
1822 - : 'border border-transparent'; 1926 + const templatePaperBorder = resolveTemplatePaperBorder(effectiveCanvasBorder);
1823 1927
1824 const innerAvailW = Math.max(0, scrollViewport.width - RULER_W); 1928 const innerAvailW = Math.max(0, scrollViewport.width - RULER_W);
1825 const innerAvailH = Math.max(0, scrollViewport.height - RULER_H); 1929 const innerAvailH = Math.max(0, scrollViewport.height - RULER_H);
@@ -1893,24 +1997,165 @@ export function LabelCanvas({ @@ -1893,24 +1997,165 @@ export function LabelCanvas({
1893 if (!selectedId) return null; 1997 if (!selectedId) return null;
1894 const el = template.elements.find((x) => x.id === selectedId); 1998 const el = template.elements.find((x) => x.id === selectedId);
1895 if (!el) return null; 1999 if (!el) return null;
1896 - return mergeLabelElementLivePatch(el, liveElementPatch); 2000 + const merged = mergeLabelElementLivePatch(el, liveElementPatch);
  2001 + return displayElementGeometry(merged);
1897 }, [selectedId, template.elements, liveElementPatch]); 2002 }, [selectedId, template.elements, liveElementPatch]);
1898 2003
1899 - const handlePointerDown = useCallback(  
1900 - (e: React.PointerEvent, id: string) => {  
1901 - // 如果按住了空格,直接返回,交给外层 panning 处理  
1902 - // 允许中键 (button 1) 拖动 2004 + const requestUpdate = useCallback((updateFn: () => void) => {
  2005 + if (nextFrameRef.current !== null) {
  2006 + cancelAnimationFrame(nextFrameRef.current);
  2007 + }
  2008 + nextFrameRef.current = requestAnimationFrame(() => {
  2009 + updateFn();
  2010 + nextFrameRef.current = null;
  2011 + });
  2012 + }, []);
  2013 +
  2014 + const detachResizeDocumentListeners = useCallback(() => {
  2015 + const listeners = resizeDocumentListenersRef.current;
  2016 + if (!listeners) return;
  2017 + document.removeEventListener('pointermove', listeners.move);
  2018 + document.removeEventListener('pointerup', listeners.up);
  2019 + document.removeEventListener('pointercancel', listeners.up);
  2020 + resizeDocumentListenersRef.current = null;
  2021 + }, []);
  2022 +
  2023 + const detachDragDocumentListeners = useCallback(() => {
  2024 + const listeners = dragDocumentListenersRef.current;
  2025 + if (!listeners) return;
  2026 + document.removeEventListener('pointermove', listeners.move);
  2027 + document.removeEventListener('pointerup', listeners.up);
  2028 + document.removeEventListener('pointercancel', listeners.up);
  2029 + dragDocumentListenersRef.current = null;
  2030 + }, []);
  2031 +
  2032 + const applyElementDragAtClient = useCallback(
  2033 + (clientX: number, clientY: number) => {
  2034 + const session = dragRef.current;
  2035 + if (!session || !canvasRef.current) return;
  2036 +
  2037 + const local = clientToCanvasLocalPoint(clientX, clientY, canvasRef.current, scale);
  2038 + if (!local) return;
  2039 +
  2040 + const { id, grabOffsetX, grabOffsetY, startLocalX, startLocalY, w, h, active } = session;
  2041 +
  2042 + if (!active) {
  2043 + const dlx = local.x - startLocalX;
  2044 + const dly = local.y - startLocalY;
  2045 + if (Math.hypot(dlx, dly) < ELEMENT_DRAG_THRESHOLD_PX) return;
  2046 + dragRef.current = { ...session, active: true };
  2047 + const domEl = document.getElementById(`element-${id}`);
  2048 + if (domEl) {
  2049 + domEl.classList.add(
  2050 + 'z-50',
  2051 + 'opacity-90',
  2052 + 'shadow-xl',
  2053 + 'ring-2',
  2054 + 'ring-blue-400',
  2055 + 'ring-offset-2',
  2056 + );
  2057 + domEl.style.cursor = 'grabbing';
  2058 + }
  2059 + }
  2060 +
  2061 + const { grabOffsetX: gox, grabOffsetY: goy, w: ew, h: eh, id: eid } = dragRef.current ?? session;
  2062 +
  2063 + requestUpdate(() => {
  2064 + const { x: rawX, y: rawY } = templatePositionFromPointerGrab(
  2065 + local.x,
  2066 + local.y,
  2067 + gox,
  2068 + goy,
  2069 + printOrientation,
  2070 + baseW,
  2071 + baseH,
  2072 + );
  2073 + const { x: snappedX, y: snappedY } = clampDragPosition(
  2074 + rawX,
  2075 + rawY,
  2076 + ew,
  2077 + eh,
  2078 + baseW,
  2079 + baseH,
  2080 + LABEL_CANVAS_SAFE_MARGIN_PX,
  2081 + printOrientation,
  2082 + );
  2083 +
  2084 + lastUpdateRef.current = { id: eid, x: snappedX, y: snappedY, width: ew, height: eh };
  2085 + onLiveElementPatchChange?.({
  2086 + id: eid,
  2087 + x: snappedX,
  2088 + y: snappedY,
  2089 + width: ew,
  2090 + height: eh,
  2091 + });
  2092 + });
  2093 + },
  2094 + [scale, printOrientation, baseW, baseH, onLiveElementPatchChange, requestUpdate],
  2095 + );
  2096 +
  2097 + const commitElementDrag = useCallback(() => {
  2098 + detachDragDocumentListeners();
  2099 + const activeId = dragRef.current?.id;
  2100 + const dragWasActive = dragRef.current?.active ?? false;
  2101 + if (activeId) {
  2102 + const domEl = document.getElementById(`element-${activeId}`);
  2103 + if (domEl) {
  2104 + domEl.classList.remove(
  2105 + 'z-50',
  2106 + 'opacity-90',
  2107 + 'shadow-xl',
  2108 + 'ring-2',
  2109 + 'ring-blue-400',
  2110 + 'ring-offset-2',
  2111 + );
  2112 + domEl.style.cursor = '';
  2113 + }
  2114 + }
  2115 + if (lastUpdateRef.current && dragWasActive) {
  2116 + const session = dragRef.current;
  2117 + const el0 = template.elements.find((x) => x.id === lastUpdateRef.current!.id);
  2118 + const { id, x, y, width, height } = lastUpdateRef.current;
  2119 + const base = el0 ? { ...el0, x, y, width: width ?? session?.w, height: height ?? session?.h } : null;
  2120 + const committed = base ? displayElementGeometry(base as LabelElement) : null;
  2121 + if (committed) {
  2122 + onUpdateElement(id, {
  2123 + x: committed.x,
  2124 + y: committed.y,
  2125 + width: committed.width,
  2126 + height: committed.height,
  2127 + });
  2128 + }
  2129 + lastUpdateRef.current = null;
  2130 + onLiveElementPatchChange?.(null);
  2131 + } else {
  2132 + lastUpdateRef.current = null;
  2133 + }
  2134 + dragRef.current = null;
  2135 + suppressCanvasClickDeselectRef.current = true;
  2136 + }, [detachDragDocumentListeners, onUpdateElement, onLiveElementPatchChange, template.elements]);
  2137 +
  2138 + const beginElementDrag = useCallback(
  2139 + (e: React.PointerEvent, elId: string) => {
1903 if (isSpacePressed || e.button === 1) return; 2140 if (isSpacePressed || e.button === 1) return;
1904 2141
1905 e.stopPropagation(); 2142 e.stopPropagation();
1906 suppressCanvasClickDeselectRef.current = true; 2143 suppressCanvasClickDeselectRef.current = true;
1907 - onSelect(id);  
1908 2144
1909 - // Focus canvas for keyboard events 2145 + detachDragDocumentListeners();
  2146 + detachResizeDocumentListeners();
  2147 +
  2148 + const el0 = template.elements.find((x) => x.id === elId);
  2149 + if (!el0) return;
  2150 + if (readElementPositionLocked(el0)) return;
  2151 +
  2152 + if (selectedId !== elId) {
  2153 + onSelect(elId);
  2154 + }
1910 canvasRef.current?.focus(); 2155 canvasRef.current?.focus();
1911 2156
1912 - const el = template.elements.find((x) => x.id === id);  
1913 - if (!el) return; 2157 + const live = mergeLabelElementLivePatch(el0, liveElementPatchRef.current);
  2158 + const canonical = displayElementGeometry(live);
1914 2159
1915 const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale); 2160 const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
1916 if (!local) return; 2161 if (!local) return;
@@ -1924,29 +2169,48 @@ export function LabelCanvas({ @@ -1924,29 +2169,48 @@ export function LabelCanvas({
1924 ); 2169 );
1925 2170
1926 dragRef.current = { 2171 dragRef.current = {
1927 - id,  
1928 - grabOffsetX: templatePtr.x - el.x,  
1929 - grabOffsetY: templatePtr.y - el.y, 2172 + id: elId,
  2173 + grabOffsetX: templatePtr.x - canonical.x,
  2174 + grabOffsetY: templatePtr.y - canonical.y,
1930 startLocalX: local.x, 2175 startLocalX: local.x,
1931 startLocalY: local.y, 2176 startLocalY: local.y,
1932 - w: el.width,  
1933 - h: el.height, 2177 + w: canonical.width,
  2178 + h: canonical.height,
1934 active: false, 2179 active: false,
1935 }; 2180 };
  2181 +
  2182 + const onMove = (ev: PointerEvent) => {
  2183 + ev.preventDefault();
  2184 + applyElementDragAtClient(ev.clientX, ev.clientY);
  2185 + };
  2186 + const onUp = (ev: PointerEvent) => {
  2187 + applyElementDragAtClient(ev.clientX, ev.clientY);
  2188 + commitElementDrag();
  2189 + };
  2190 + dragDocumentListenersRef.current = { move: onMove, up: onUp };
  2191 + document.addEventListener('pointermove', onMove);
  2192 + document.addEventListener('pointerup', onUp);
  2193 + document.addEventListener('pointercancel', onUp);
  2194 + canvasRef.current?.setPointerCapture?.(e.pointerId);
  2195 + capturedPointerIdRef.current = e.pointerId;
1936 }, 2196 },
1937 - [template.elements, onSelect, isSpacePressed, scale, printOrientation, baseW, baseH] 2197 + [
  2198 + isSpacePressed,
  2199 + selectedId,
  2200 + onSelect,
  2201 + detachDragDocumentListeners,
  2202 + detachResizeDocumentListeners,
  2203 + template.elements,
  2204 + scale,
  2205 + printOrientation,
  2206 + baseW,
  2207 + baseH,
  2208 + applyElementDragAtClient,
  2209 + commitElementDrag,
  2210 + onUpdateElement,
  2211 + ],
1938 ); 2212 );
1939 2213
1940 - const requestUpdate = useCallback((updateFn: () => void) => {  
1941 - if (nextFrameRef.current !== null) {  
1942 - cancelAnimationFrame(nextFrameRef.current);  
1943 - }  
1944 - nextFrameRef.current = requestAnimationFrame(() => {  
1945 - updateFn();  
1946 - nextFrameRef.current = null;  
1947 - });  
1948 - }, []);  
1949 -  
1950 const beginPaperResize = useCallback((e: React.PointerEvent, edge: PaperResizeEdge) => { 2214 const beginPaperResize = useCallback((e: React.PointerEvent, edge: PaperResizeEdge) => {
1951 e.stopPropagation(); 2215 e.stopPropagation();
1952 paperResizeRef.current = { 2216 paperResizeRef.current = {
@@ -1963,15 +2227,6 @@ export function LabelCanvas({ @@ -1963,15 +2227,6 @@ export function LabelCanvas({
1963 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId); 2227 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
1964 }, [template.width, template.height, template.elements]); 2228 }, [template.width, template.height, template.elements]);
1965 2229
1966 - const detachResizeDocumentListeners = useCallback(() => {  
1967 - const listeners = resizeDocumentListenersRef.current;  
1968 - if (!listeners) return;  
1969 - document.removeEventListener('pointermove', listeners.move);  
1970 - document.removeEventListener('pointerup', listeners.up);  
1971 - document.removeEventListener('pointercancel', listeners.up);  
1972 - resizeDocumentListenersRef.current = null;  
1973 - }, []);  
1974 -  
1975 const applyElementResizeAtClient = useCallback( 2230 const applyElementResizeAtClient = useCallback(
1976 (clientX: number, clientY: number) => { 2231 (clientX: number, clientY: number) => {
1977 const session = resizeRef.current; 2232 const session = resizeRef.current;
@@ -2026,27 +2281,44 @@ export function LabelCanvas({ @@ -2026,27 +2281,44 @@ export function LabelCanvas({
2026 } 2281 }
2027 detachResizeDocumentListeners(); 2282 detachResizeDocumentListeners();
2028 if (lastUpdateRef.current) { 2283 if (lastUpdateRef.current) {
  2284 + const el0 = template.elements.find((x) => x.id === lastUpdateRef.current!.id);
2029 const { id, ...patch } = lastUpdateRef.current; 2285 const { id, ...patch } = lastUpdateRef.current;
2030 - onUpdateElement(id, patch); 2286 + if (el0) {
  2287 + const committed = displayElementGeometry({ ...el0, ...patch } as LabelElement);
  2288 + onUpdateElement(id, {
  2289 + x: committed.x,
  2290 + y: committed.y,
  2291 + width: committed.width,
  2292 + height: committed.height,
  2293 + });
  2294 + }
2031 } 2295 }
2032 lastUpdateRef.current = null; 2296 lastUpdateRef.current = null;
2033 resizeRef.current = null; 2297 resizeRef.current = null;
2034 onLiveElementPatchChange?.(null); 2298 onLiveElementPatchChange?.(null);
2035 suppressCanvasClickDeselectRef.current = true; 2299 suppressCanvasClickDeselectRef.current = true;
2036 - }, [detachResizeDocumentListeners, onUpdateElement, onLiveElementPatchChange]); 2300 + }, [detachResizeDocumentListeners, onUpdateElement, onLiveElementPatchChange, template.elements]);
2037 2301
2038 const beginElementResize = useCallback( 2302 const beginElementResize = useCallback(
2039 (e: React.PointerEvent, elId: string, handleId: string) => { 2303 (e: React.PointerEvent, elId: string, handleId: string) => {
2040 e.stopPropagation(); 2304 e.stopPropagation();
2041 e.preventDefault(); 2305 e.preventDefault();
2042 suppressCanvasClickDeselectRef.current = true; 2306 suppressCanvasClickDeselectRef.current = true;
2043 - onSelect(elId);  
2044 2307
  2308 + if (selectedId !== elId) {
  2309 + onSelect(elId);
  2310 + }
  2311 +
  2312 + detachDragDocumentListeners();
  2313 + dragRef.current = null;
2045 detachResizeDocumentListeners(); 2314 detachResizeDocumentListeners();
2046 2315
2047 const el0 = template.elements.find((x) => x.id === elId); 2316 const el0 = template.elements.find((x) => x.id === elId);
2048 if (!el0) return; 2317 if (!el0) return;
  2318 + if (readElementPositionLocked(el0)) return;
2049 2319
  2320 + const live = mergeLabelElementLivePatch(el0, liveElementPatchRef.current);
  2321 + const canonical = displayElementGeometry(live);
2050 const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale); 2322 const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
2051 if (!local) return; 2323 if (!local) return;
2052 resizeRef.current = { 2324 resizeRef.current = {
@@ -2054,17 +2326,17 @@ export function LabelCanvas({ @@ -2054,17 +2326,17 @@ export function LabelCanvas({
2054 corner: handleId, 2326 corner: handleId,
2055 startLocalX: local.x, 2327 startLocalX: local.x,
2056 startLocalY: local.y, 2328 startLocalY: local.y,
2057 - w: el0.width,  
2058 - h: el0.height,  
2059 - elX: el0.x,  
2060 - elY: el0.y, 2329 + w: canonical.width,
  2330 + h: canonical.height,
  2331 + elX: canonical.x,
  2332 + elY: canonical.y,
2061 }; 2333 };
2062 onLiveElementPatchChange?.({ 2334 onLiveElementPatchChange?.({
2063 id: elId, 2335 id: elId,
2064 - x: el0.x,  
2065 - y: el0.y,  
2066 - width: el0.width,  
2067 - height: el0.height, 2336 + x: canonical.x,
  2337 + y: canonical.y,
  2338 + width: canonical.width,
  2339 + height: canonical.height,
2068 }); 2340 });
2069 2341
2070 const onMove = (ev: PointerEvent) => { 2342 const onMove = (ev: PointerEvent) => {
@@ -2114,57 +2386,9 @@ export function LabelCanvas({ @@ -2114,57 +2386,9 @@ export function LabelCanvas({
2114 scrollContainerRef.current.scrollTop = panStartRef.current.scrollTop - dy; 2386 scrollContainerRef.current.scrollTop = panStartRef.current.scrollTop - dy;
2115 return; 2387 return;
2116 } 2388 }
2117 - // Drag Element  
2118 - if (dragRef.current) {  
2119 - const { id, grabOffsetX, grabOffsetY, startLocalX, startLocalY, w, h, active } =  
2120 - dragRef.current;  
2121 - const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);  
2122 - if (!local) return;  
2123 -  
2124 - if (!active) {  
2125 - const dlx = local.x - startLocalX;  
2126 - const dly = local.y - startLocalY;  
2127 - if (Math.hypot(dlx, dly) < ELEMENT_DRAG_THRESHOLD_PX) return;  
2128 - dragRef.current = { ...dragRef.current, active: true };  
2129 - const domEl = document.getElementById(`element-${id}`);  
2130 - if (domEl) {  
2131 - domEl.classList.add('z-50', 'opacity-90', 'shadow-xl', 'ring-2', 'ring-blue-400', 'ring-offset-2');  
2132 - domEl.style.cursor = 'grabbing';  
2133 - }  
2134 - canvasRef.current?.setPointerCapture?.(e.pointerId);  
2135 - capturedPointerIdRef.current = e.pointerId;  
2136 - }  
2137 -  
2138 - requestUpdate(() => {  
2139 - const { x: rawX, y: rawY } = templatePositionFromPointerGrab(  
2140 - local.x,  
2141 - local.y,  
2142 - grabOffsetX,  
2143 - grabOffsetY,  
2144 - printOrientation,  
2145 - baseW,  
2146 - baseH,  
2147 - );  
2148 - const { x: snappedX, y: snappedY } = clampDragPosition(  
2149 - rawX,  
2150 - rawY,  
2151 - w,  
2152 - h,  
2153 - baseW,  
2154 - baseH,  
2155 - LABEL_CANVAS_SAFE_MARGIN_PX,  
2156 - printOrientation,  
2157 - );  
2158 -  
2159 - lastUpdateRef.current = { id, x: snappedX, y: snappedY };  
2160 - onLiveElementPatchChange?.({  
2161 - id,  
2162 - x: snappedX,  
2163 - y: snappedY,  
2164 - width: w,  
2165 - height: h,  
2166 - });  
2167 - }); 2389 + // 拖拽由 document 级监听处理(beginElementDrag)
  2390 + if (dragDocumentListenersRef.current) {
  2391 + return;
2168 } 2392 }
2169 2393
2170 // Resize Element(document 级监听处理缩放,此处跳过) 2394 // Resize Element(document 级监听处理缩放,此处跳过)
@@ -2298,7 +2522,6 @@ export function LabelCanvas({ @@ -2298,7 +2522,6 @@ export function LabelCanvas({
2298 } 2522 }
2299 2523
2300 const activeId = dragRef.current?.id || resizeRef.current?.id; 2524 const activeId = dragRef.current?.id || resizeRef.current?.id;
2301 - const dragWasActive = dragRef.current?.active ?? false;  
2302 if (activeId) { 2525 if (activeId) {
2303 const domEl = document.getElementById(`element-${activeId}`); 2526 const domEl = document.getElementById(`element-${activeId}`);
2304 if (domEl) { 2527 if (domEl) {
@@ -2317,17 +2540,19 @@ export function LabelCanvas({ @@ -2317,17 +2540,19 @@ export function LabelCanvas({
2317 } 2540 }
2318 capturedPointerIdRef.current = null; 2541 capturedPointerIdRef.current = null;
2319 2542
2320 - if (resizeDocumentListenersRef.current) {  
2321 - commitElementResize();  
2322 - } else if (lastUpdateRef.current && (dragWasActive || resizeRef.current)) {  
2323 - const { id, ...patch } = lastUpdateRef.current;  
2324 - onUpdateElement(id, patch);  
2325 - lastUpdateRef.current = null;  
2326 - onLiveElementPatchChange?.(null);  
2327 - } else {  
2328 - lastUpdateRef.current = null; 2543 + // 拖拽/缩放由 document 级 listener 在 pointerup 时 commit,此处只做兜底清理
  2544 + if (!dragDocumentListenersRef.current && !resizeDocumentListenersRef.current) {
  2545 + if (lastUpdateRef.current && ((dragRef.current?.active ?? false) || resizeRef.current)) {
  2546 + const { id, ...patch } = lastUpdateRef.current;
  2547 + onUpdateElement(id, patch);
  2548 + lastUpdateRef.current = null;
  2549 + onLiveElementPatchChange?.(null);
  2550 + } else {
  2551 + lastUpdateRef.current = null;
  2552 + }
2329 } 2553 }
2330 2554
  2555 + detachDragDocumentListeners();
2331 detachResizeDocumentListeners(); 2556 detachResizeDocumentListeners();
2332 dragRef.current = null; 2557 dragRef.current = null;
2333 resizeRef.current = null; 2558 resizeRef.current = null;
@@ -2335,10 +2560,13 @@ export function LabelCanvas({ @@ -2335,10 +2560,13 @@ export function LabelCanvas({
2335 setPaperResizeCursor(null); 2560 setPaperResizeCursor(null);
2336 document.body.style.cursor = ''; 2561 document.body.style.cursor = '';
2337 }, 2562 },
2338 - [onUpdateElement, onLiveElementPatchChange, isPanning, detachResizeDocumentListeners, commitElementResize], 2563 + [onUpdateElement, onLiveElementPatchChange, isPanning, detachResizeDocumentListeners, detachDragDocumentListeners],
2339 ); 2564 );
2340 2565
2341 - useEffect(() => () => detachResizeDocumentListeners(), [detachResizeDocumentListeners]); 2566 + useEffect(() => () => {
  2567 + detachResizeDocumentListeners();
  2568 + detachDragDocumentListeners();
  2569 + }, [detachResizeDocumentListeners, detachDragDocumentListeners]);
2342 2570
2343 useEffect(() => { 2571 useEffect(() => {
2344 const onKeyDown = (e: KeyboardEvent) => { 2572 const onKeyDown = (e: KeyboardEvent) => {
@@ -2395,6 +2623,7 @@ export function LabelCanvas({ @@ -2395,6 +2623,7 @@ export function LabelCanvas({
2395 2623
2396 const el = template.elements.find(x => x.id === selectedId); 2624 const el = template.elements.find(x => x.id === selectedId);
2397 if (!el) return; 2625 if (!el) return;
  2626 + if (readElementPositionLocked(el)) return;
2398 2627
2399 // allow typing in inputs without triggering move? 2628 // allow typing in inputs without triggering move?
2400 // Actually our elements are not inputs (unless we implement inline edit). 2629 // Actually our elements are not inputs (unless we implement inline edit).
@@ -2429,6 +2658,15 @@ export function LabelCanvas({ @@ -2429,6 +2658,15 @@ export function LabelCanvas({
2429 2658
2430 }, [selectedId, template.elements, onUpdateElement, onDeleteElement, onSelect, baseW, baseH, printOrientation]); 2659 }, [selectedId, template.elements, onUpdateElement, onDeleteElement, onSelect, baseW, baseH, printOrientation]);
2431 2660
  2661 + const handleToggleElementPositionLock = useCallback(
  2662 + (id: string, locked: boolean) => {
  2663 + const el = template.elements.find((x) => x.id === id);
  2664 + if (!el) return;
  2665 + onUpdateElement(id, patchElementPositionLocked(el, locked));
  2666 + },
  2667 + [template.elements, onUpdateElement],
  2668 + );
  2669 +
2432 const canvasClick = () => onSelect(null); 2670 const canvasClick = () => onSelect(null);
2433 2671
2434 // 容器的 Pan 处理 2672 // 容器的 Pan 处理
@@ -2476,7 +2714,21 @@ export function LabelCanvas({ @@ -2476,7 +2714,21 @@ export function LabelCanvas({
2476 > 2714 >
2477 {/* Label Preview 标题 + 网格/预览/缩放 */} 2715 {/* Label Preview 标题 + 网格/预览/缩放 */}
2478 <div className="shrink-0 px-4 py-2 border-b border-gray-200 bg-white flex flex-nowrap items-center justify-between gap-3 z-10 min-h-[44px]"> 2716 <div className="shrink-0 px-4 py-2 border-b border-gray-200 bg-white flex flex-nowrap items-center justify-between gap-3 z-10 min-h-[44px]">
2479 - <span className="text-sm font-medium text-gray-700 shrink-0 min-w-0 truncate">Label Preview</span> 2717 + <div className="flex min-w-0 shrink-0 flex-nowrap items-center gap-2">
  2718 + <span className="text-sm font-medium text-gray-700 truncate">Label Preview</span>
  2719 + <button
  2720 + type="button"
  2721 + onClick={() => setAvailableElementsOpen((open) => !open)}
  2722 + className={cn(
  2723 + 'h-8 shrink-0 rounded border px-3 text-xs font-medium shadow-sm transition-all active:scale-95',
  2724 + availableElementsOpen
  2725 + ? 'border-blue-300 bg-blue-50 text-blue-800 hover:bg-blue-100'
  2726 + : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50',
  2727 + )}
  2728 + >
  2729 + Available Elements
  2730 + </button>
  2731 + </div>
2480 <div className="flex flex-nowrap items-center justify-end gap-2 shrink-0 min-w-0"> 2732 <div className="flex flex-nowrap items-center justify-end gap-2 shrink-0 min-w-0">
2481 {onPrintOrientationChange ? ( 2733 {onPrintOrientationChange ? (
2482 <PrintOrientationToggle 2734 <PrintOrientationToggle
@@ -2609,6 +2861,18 @@ export function LabelCanvas({ @@ -2609,6 +2861,18 @@ export function LabelCanvas({
2609 onPointerUp={handleContainerPointerUp} 2861 onPointerUp={handleContainerPointerUp}
2610 onPointerLeave={handleContainerPointerUp} 2862 onPointerLeave={handleContainerPointerUp}
2611 > 2863 >
  2864 + {availableElementsOpen ? (
  2865 + <AvailableElementsPanel
  2866 + elements={template.elements}
  2867 + selectedId={selectedId}
  2868 + onSelect={(id) => {
  2869 + onSelect(id);
  2870 + canvasRef.current?.focus();
  2871 + }}
  2872 + onTogglePositionLock={handleToggleElementPositionLock}
  2873 + onClose={() => setAvailableElementsOpen(false)}
  2874 + />
  2875 + ) : null}
2612 <div 2876 <div
2613 className="pointer-events-none absolute inset-0 z-0 bg-gray-100" 2877 className="pointer-events-none absolute inset-0 z-0 bg-gray-100"
2614 aria-hidden 2878 aria-hidden
@@ -2689,7 +2953,7 @@ export function LabelCanvas({ @@ -2689,7 +2953,7 @@ export function LabelCanvas({
2689 className={cn( 2953 className={cn(
2690 'absolute left-0 top-0 shadow-lg outline-none', 2954 'absolute left-0 top-0 shadow-lg outline-none',
2691 isRotatedPrint ? 'overflow-visible' : 'overflow-hidden', 2955 isRotatedPrint ? 'overflow-visible' : 'overflow-hidden',
2692 - canvasBorderClass, 2956 + templatePaperBorder.className,
2693 isPanning ? 'cursor-grabbing' : 'cursor-grab' 2957 isPanning ? 'cursor-grabbing' : 'cursor-grab'
2694 )} 2958 )}
2695 style={{ 2959 style={{
@@ -2699,6 +2963,7 @@ export function LabelCanvas({ @@ -2699,6 +2963,7 @@ export function LabelCanvas({
2699 transformOrigin: 'top left', 2963 transformOrigin: 'top left',
2700 pointerEvents: isSpacePressed ? 'none' : 'auto', 2964 pointerEvents: isSpacePressed ? 'none' : 'auto',
2701 cursor: paperResizeCursor ?? undefined, 2965 cursor: paperResizeCursor ?? undefined,
  2966 + ...templatePaperBorder.style,
2702 }} 2967 }}
2703 onClick={(e) => { 2968 onClick={(e) => {
2704 if (suppressCanvasClickDeselectRef.current) { 2969 if (suppressCanvasClickDeselectRef.current) {
@@ -2708,13 +2973,14 @@ export function LabelCanvas({ @@ -2708,13 +2973,14 @@ export function LabelCanvas({
2708 // 点击画布空白处取消选中 2973 // 点击画布空白处取消选中
2709 const target = e.target as HTMLElement; 2974 const target = e.target as HTMLElement;
2710 const isOnElement = target.closest('[id^="element-"]'); 2975 const isOnElement = target.closest('[id^="element-"]');
  2976 + const isOnDragOverlay = target.closest('[data-element-drag-overlay="true"]');
2711 const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]'); 2977 const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]');
2712 const isOnPaperResize = 2978 const isOnPaperResize =
2713 target.closest('[data-paper-resize-handle="true"]') || 2979 target.closest('[data-paper-resize-handle="true"]') ||
2714 target.closest('[title*="Drag to resize paper"]') || 2980 target.closest('[title*="Drag to resize paper"]') ||
2715 target.closest('[title*="Drag to increase paper height"]') || 2981 target.closest('[title*="Drag to increase paper height"]') ||
2716 target.closest('[title*="Drag to increase paper width"]'); 2982 target.closest('[title*="Drag to increase paper width"]');
2717 - if (!isOnElement && !isOnResizeHandle && !isOnPaperResize) { 2983 + if (!isOnElement && !isOnDragOverlay && !isOnResizeHandle && !isOnPaperResize) {
2718 onSelect(null); 2984 onSelect(null);
2719 } 2985 }
2720 }} 2986 }}
@@ -2722,6 +2988,7 @@ export function LabelCanvas({ @@ -2722,6 +2988,7 @@ export function LabelCanvas({
2722 // 空白处按下即开始平移(在画布内且未点到元素/纸张拖拽条) 2988 // 空白处按下即开始平移(在画布内且未点到元素/纸张拖拽条)
2723 const target = e.target as HTMLElement; 2989 const target = e.target as HTMLElement;
2724 const isOnElement = target.closest('[id^="element-"]'); 2990 const isOnElement = target.closest('[id^="element-"]');
  2991 + const isOnDragOverlay = target.closest('[data-element-drag-overlay="true"]');
2725 const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]'); 2992 const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]');
2726 const isOnPaperResize = 2993 const isOnPaperResize =
2727 target.closest('[data-paper-resize-handle="true"]') || 2994 target.closest('[data-paper-resize-handle="true"]') ||
@@ -2729,7 +2996,7 @@ export function LabelCanvas({ @@ -2729,7 +2996,7 @@ export function LabelCanvas({
2729 target.closest('[title*="Drag to increase paper height"]') || 2996 target.closest('[title*="Drag to increase paper height"]') ||
2730 target.closest('[title*="Drag to increase paper width"]'); 2997 target.closest('[title*="Drag to increase paper width"]');
2731 const isOnCanvasArea = canvasRef.current?.contains(target); 2998 const isOnCanvasArea = canvasRef.current?.contains(target);
2732 - if (isOnCanvasArea && !isOnElement && !isOnResizeHandle && !isOnPaperResize && !dragRef.current && !resizeRef.current) { 2999 + if (isOnCanvasArea && !isOnElement && !isOnDragOverlay && !isOnResizeHandle && !isOnPaperResize && !dragRef.current && !resizeRef.current) {
2733 if (isSpacePressed || e.button === 1) { 3000 if (isSpacePressed || e.button === 1) {
2734 e.preventDefault(); 3001 e.preventDefault();
2735 e.stopPropagation(); 3002 e.stopPropagation();
@@ -2778,27 +3045,28 @@ export function LabelCanvas({ @@ -2778,27 +3045,28 @@ export function LabelCanvas({
2778 {selectedElementLive 3045 {selectedElementLive
2779 ? (() => { 3046 ? (() => {
2780 const el = selectedElementLive; 3047 const el = selectedElementLive;
2781 - const lineCls = "pointer-events-none absolute z-[2] border-blue-600"; 3048 + const lineCls =
  3049 + "pointer-events-none absolute z-[2] border-blue-600 border-dashed";
2782 return ( 3050 return (
2783 <> 3051 <>
2784 <div 3052 <div
2785 - className={cn(lineCls, "left-0 right-0 border-t border-dashed")}  
2786 - style={{ top: el.y }} 3053 + className={cn(lineCls, "left-0 right-0 border-t")}
  3054 + style={{ top: el.y, borderTopWidth: ELEMENT_SELECTION_LINE_WIDTH }}
2787 aria-hidden 3055 aria-hidden
2788 /> 3056 />
2789 <div 3057 <div
2790 - className={cn(lineCls, "left-0 right-0 border-t border-dashed")}  
2791 - style={{ top: el.y + el.height }} 3058 + className={cn(lineCls, "left-0 right-0 border-t")}
  3059 + style={{ top: el.y + el.height, borderTopWidth: ELEMENT_SELECTION_LINE_WIDTH }}
2792 aria-hidden 3060 aria-hidden
2793 /> 3061 />
2794 <div 3062 <div
2795 - className={cn(lineCls, "top-0 bottom-0 border-l border-dashed")}  
2796 - style={{ left: el.x }} 3063 + className={cn(lineCls, "top-0 bottom-0 border-l")}
  3064 + style={{ left: el.x, borderLeftWidth: ELEMENT_SELECTION_LINE_WIDTH }}
2797 aria-hidden 3065 aria-hidden
2798 /> 3066 />
2799 <div 3067 <div
2800 - className={cn(lineCls, "top-0 bottom-0 border-l border-dashed")}  
2801 - style={{ left: el.x + el.width }} 3068 + className={cn(lineCls, "top-0 bottom-0 border-l")}
  3069 + style={{ left: el.x + el.width, borderLeftWidth: ELEMENT_SELECTION_LINE_WIDTH }}
2802 aria-hidden 3070 aria-hidden
2803 /> 3071 />
2804 </> 3072 </>
@@ -2806,15 +3074,19 @@ export function LabelCanvas({ @@ -2806,15 +3074,19 @@ export function LabelCanvas({
2806 })() 3074 })()
2807 : null} 3075 : null}
2808 {template.elements.map((el) => { 3076 {template.elements.map((el) => {
2809 - const effectiveEl = mergeLabelElementLivePatch(el, liveElementPatch); 3077 + const merged = mergeLabelElementLivePatch(el, liveElementPatch);
  3078 + const effectiveEl = displayElementGeometry(merged);
2810 const isPrintField = isPrintInputElement(el); 3079 const isPrintField = isPrintInputElement(el);
2811 const isSelected = selectedId === el.id; 3080 const isSelected = selectedId === el.id;
  3081 + const positionLocked = readElementPositionLocked(el);
  3082 + const isVerticalRotation = readElementRotation(effectiveEl) === 'vertical';
2812 return ( 3083 return (
2813 <div 3084 <div
2814 key={el.id} 3085 key={el.id}
2815 id={`element-${el.id}`} 3086 id={`element-${el.id}`}
2816 className={cn( 3087 className={cn(
2817 - 'absolute box-border cursor-move overflow-visible transition-shadow', 3088 + 'absolute box-border overflow-visible transition-shadow',
  3089 + positionLocked ? 'cursor-default' : 'cursor-move',
2818 el.border === 'line' && 'border border-gray-400', 3090 el.border === 'line' && 'border border-gray-400',
2819 el.border === 'dotted' && 'border border-dotted border-gray-400', 3091 el.border === 'dotted' && 'border border-dotted border-gray-400',
2820 isSelected && 'z-10', 3092 isSelected && 'z-10',
@@ -2829,18 +3101,32 @@ export function LabelCanvas({ @@ -2829,18 +3101,32 @@ export function LabelCanvas({
2829 e.stopPropagation(); 3101 e.stopPropagation();
2830 onSelect(el.id); 3102 onSelect(el.id);
2831 }} 3103 }}
2832 - onPointerDown={(e) => handlePointerDown(e, el.id)} 3104 + onPointerDown={(e) => {
  3105 + if (readElementPositionLocked(el)) return;
  3106 + if ((e.target as HTMLElement).closest('[data-element-resize-handle="true"]')) return;
  3107 + beginElementDrag(e, el.id);
  3108 + }}
2833 > 3109 >
2834 <div 3110 <div
2835 className={cn( 3111 className={cn(
2836 - 'w-full h-full min-h-0 relative overflow-hidden', 3112 + 'w-full h-full min-h-0 relative',
  3113 + isVerticalRotation ? 'overflow-visible' : 'overflow-hidden',
2837 isPrintField && 3114 isPrintField &&
2838 !isSelected && 3115 !isSelected &&
2839 'rounded-sm border-2 border-dashed border-amber-500/85 bg-amber-50/35', 3116 'rounded-sm border-2 border-dashed border-amber-500/85 bg-amber-50/35',
2840 )} 3117 )}
2841 > 3118 >
2842 - <ElementContent el={el} isAppPrintField={isPrintField} /> 3119 + <ElementContent el={effectiveEl} isAppPrintField={isPrintField} />
2843 </div> 3120 </div>
  3121 + {positionLocked ? (
  3122 + <div
  3123 + className="pointer-events-none absolute bottom-0 right-0 z-[5] translate-x-1/4 translate-y-1/4 rounded bg-gray-600/90 p-0.5 text-white shadow-sm"
  3124 + title="Position locked"
  3125 + aria-hidden
  3126 + >
  3127 + <Lock className="h-3 w-3" />
  3128 + </div>
  3129 + ) : null}
2844 </div> 3130 </div>
2845 ); 3131 );
2846 })} 3132 })}
@@ -2855,6 +3141,8 @@ export function LabelCanvas({ @@ -2855,6 +3141,8 @@ export function LabelCanvas({
2855 displayUnit={previewRulerUnit} 3141 displayUnit={previewRulerUnit}
2856 printOrientation={printOrientation} 3142 printOrientation={printOrientation}
2857 interactive 3143 interactive
  3144 + positionLocked={readElementPositionLocked(selectedElementLive)}
  3145 + onDragPointerDown={(e) => beginElementDrag(e, selectedElementLive.id)}
2858 onResizePointerDown={(e, handleId) => 3146 onResizePointerDown={(e, handleId) =>
2859 beginElementResize(e, selectedElementLive.id, handleId) 3147 beginElementResize(e, selectedElementLive.id, handleId)
2860 } 3148 }
@@ -2905,12 +3193,7 @@ export function LabelPreviewOnly({ @@ -2905,12 +3193,7 @@ export function LabelPreviewOnly({
2905 const displayW = baseW * scaleToFit; 3193 const displayW = baseW * scaleToFit;
2906 const displayH = baseH * scaleToFit; 3194 const displayH = baseH * scaleToFit;
2907 const effectiveCanvasBorder = canvasBorder ?? template.border ?? 'none'; 3195 const effectiveCanvasBorder = canvasBorder ?? template.border ?? 'none';
2908 - const previewBorderClass =  
2909 - effectiveCanvasBorder === 'line'  
2910 - ? 'border border-gray-500'  
2911 - : effectiveCanvasBorder === 'dotted'  
2912 - ? 'border border-dotted border-gray-500'  
2913 - : 'border border-transparent'; 3196 + const templatePaperBorder = resolveTemplatePaperBorder(effectiveCanvasBorder);
2914 // 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致 3197 // 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致
2915 return ( 3198 return (
2916 <div 3199 <div
@@ -2918,8 +3201,8 @@ export function LabelPreviewOnly({ @@ -2918,8 +3201,8 @@ export function LabelPreviewOnly({
2918 style={{ minWidth: displayW + 32 }} 3201 style={{ minWidth: displayW + 32 }}
2919 > 3202 >
2920 <div 3203 <div
2921 - style={{ width: displayW, height: displayH }}  
2922 - className={cn("relative overflow-hidden bg-white shadow-lg", previewBorderClass)} 3204 + style={{ width: displayW, height: displayH, ...templatePaperBorder.style }}
  3205 + className={cn("relative overflow-hidden bg-white shadow-lg", templatePaperBorder.className)}
2923 > 3206 >
2924 <div 3207 <div
2925 className="origin-top-left overflow-hidden" 3208 className="origin-top-left overflow-hidden"
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/NutritionFactsPanel.tsx 0 → 100644
  1 +import React from 'react';
  2 +import { cn } from '../../ui/utils';
  3 +import {
  4 + buildNutritionFactsViewModel,
  5 + DEFAULT_NUTRITION_FOOTER_NOTE,
  6 + NUTRITION_AMOUNT_COL_WIDTH,
  7 + NUTRITION_BODY_FONT_SIZE,
  8 + NUTRITION_PCT_COL_WIDTH,
  9 + type NutritionDivider,
  10 + type NutritionFactsViewModel,
  11 +} from '../../../lib/nutritionFactsLayout';
  12 +
  13 +/** 双下划线两线之间的间距(px) */
  14 +const DOUBLE_LINE_GAP_PX = 3;
  15 +
  16 +/** FreightSans 仅有 Bold 字重;正文区改用常规字体以保证 12px 非加粗展示 */
  17 +function nutritionPanelBodyFontFamily(configured: string): string {
  18 + const lower = String(configured ?? '').toLowerCase();
  19 + if (lower.includes('freightsans') || lower.includes('bold')) {
  20 + return 'Roboto, Arial, sans-serif';
  21 + }
  22 + return configured || 'Roboto, Arial, sans-serif';
  23 +}
  24 +
  25 +/** 单/双下划线:双线之间留空,不重叠 */
  26 +function NutritionDivider({ kind }: { kind: Exclude<NutritionDivider, 'none'> }) {
  27 + if (kind === 'thin') {
  28 + return <div className="w-full border-b border-black" aria-hidden />;
  29 + }
  30 + return (
  31 + <div className="w-full pb-0.5" aria-hidden>
  32 + <div className="w-full border-b border-black" />
  33 + <div className="w-full border-b border-black" style={{ marginTop: `${DOUBLE_LINE_GAP_PX}px` }} />
  34 + </div>
  35 + );
  36 +}
  37 +
  38 +export function NutritionFactsPanel({
  39 + cfg,
  40 + fontFamily,
  41 + className,
  42 +}: {
  43 + cfg: Record<string, unknown>;
  44 + fontFamily: string;
  45 + className?: string;
  46 +}) {
  47 + const model = buildNutritionFactsViewModel(cfg);
  48 + const bodyFont = nutritionPanelBodyFontFamily(fontFamily);
  49 + const bodySize = NUTRITION_BODY_FONT_SIZE;
  50 + const footerSize = Math.max(8, Math.round(bodySize * 0.67));
  51 +
  52 + return (
  53 + <div
  54 + className={cn(
  55 + 'w-full h-full overflow-hidden flex flex-col bg-white text-black px-1.5 py-0.5',
  56 + className,
  57 + )}
  58 + style={{ fontFamily: bodyFont, fontWeight: 400, lineHeight: 1.2 }}
  59 + >
  60 + <div
  61 + className="pb-0.5 leading-none tracking-tight"
  62 + style={{
  63 + fontSize: `${model.titleFontSize}px`,
  64 + fontWeight: 700,
  65 + fontFamily: fontFamily.includes('Freight') ? fontFamily : bodyFont,
  66 + }}
  67 + >
  68 + Nutrition Facts
  69 + <NutritionDivider kind="double" />
  70 + </div>
  71 +
  72 + <HeaderRow label={model.servingsLabel} value={model.servingsValue} size={bodySize} divider="thin" />
  73 + <HeaderRow
  74 + label={model.servingSizeLabel}
  75 + value={model.servingSizeValue}
  76 + size={bodySize}
  77 + divider="double"
  78 + />
  79 +
  80 + <div className="py-[3px]" style={{ fontSize: `${bodySize}px`, fontWeight: 400 }}>
  81 + <div className="flex items-center justify-between">
  82 + <span style={{ fontWeight: 700 }}>{model.caloriesLabel}</span>
  83 + <span className="tabular-nums whitespace-nowrap" style={{ fontWeight: 400 }}>
  84 + {model.caloriesAmountText || model.caloriesValue}
  85 + </span>
  86 + </div>
  87 + <NutritionDivider kind="double" />
  88 + </div>
  89 +
  90 + <div className="flex-1 min-h-0 overflow-hidden">
  91 + {model.rows.map((row) => (
  92 + <NutrientRow key={row.key} row={row} bodySize={bodySize} />
  93 + ))}
  94 + </div>
  95 +
  96 + <p
  97 + className="mt-1 pt-1 border-t border-black leading-[1.2]"
  98 + style={{ fontSize: `${footerSize}px`, fontWeight: 400 }}
  99 + >
  100 + {DEFAULT_NUTRITION_FOOTER_NOTE.split(/(\b2000\b)/).map((part, i) =>
  101 + part === '2000' ? (
  102 + <span key={i} style={{ fontWeight: 700 }}>
  103 + 2000
  104 + </span>
  105 + ) : (
  106 + part
  107 + ),
  108 + )}
  109 + </p>
  110 + </div>
  111 + );
  112 +}
  113 +
  114 +function NutrientRow({
  115 + row,
  116 + bodySize,
  117 +}: {
  118 + row: {
  119 + key: string;
  120 + label: string;
  121 + amountText: string;
  122 + dailyValueText: string;
  123 + labelBold: boolean;
  124 + indent: boolean;
  125 + dividerAfter: NutritionDivider;
  126 + };
  127 + bodySize: number;
  128 +}) {
  129 + return (
  130 + <div className="py-[3px]">
  131 + <div
  132 + className="flex flex-row flex-nowrap items-center gap-0.5 min-w-0 w-full"
  133 + style={{ fontSize: `${bodySize}px`, fontWeight: 400 }}
  134 + >
  135 + <span
  136 + className={cn(
  137 + 'flex-1 min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-left',
  138 + row.indent && 'pl-2.5',
  139 + )}
  140 + style={{ fontWeight: row.labelBold ? 700 : 400 }}
  141 + >
  142 + {row.label}
  143 + </span>
  144 + <span
  145 + className="shrink-0 text-center tabular-nums whitespace-nowrap overflow-hidden text-ellipsis"
  146 + style={{ width: NUTRITION_AMOUNT_COL_WIDTH, fontWeight: 400 }}
  147 + >
  148 + {row.amountText}
  149 + </span>
  150 + <span
  151 + className="shrink-0 text-right tabular-nums whitespace-nowrap"
  152 + style={{ width: NUTRITION_PCT_COL_WIDTH, fontWeight: 400 }}
  153 + >
  154 + {row.dailyValueText}
  155 + </span>
  156 + </div>
  157 + {row.dividerAfter !== 'none' ? <NutritionDivider kind={row.dividerAfter} /> : null}
  158 + </div>
  159 + );
  160 +}
  161 +
  162 +function HeaderRow({
  163 + label,
  164 + value,
  165 + size,
  166 + divider,
  167 +}: {
  168 + label: string;
  169 + value: string;
  170 + size: number;
  171 + divider: NutritionDivider;
  172 +}) {
  173 + return (
  174 + <div className="py-[3px]" style={{ fontSize: `${size}px`, fontWeight: 400 }}>
  175 + <div className="flex items-center justify-between">
  176 + <span>{label}</span>
  177 + <span className="tabular-nums whitespace-nowrap">{value}</span>
  178 + </div>
  179 + {divider !== 'none' ? <NutritionDivider kind={divider} /> : null}
  180 + </div>
  181 + );
  182 +}
  183 +
  184 +export { buildNutritionFactsViewModel, type NutritionFactsViewModel };
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/PropertiesPanel.tsx
1 import React, { useEffect, useState } from 'react'; 1 import React, { useEffect, useState } from 'react';
2 -import { Building2, Check, Mail, Map, MapPin, Mailbox } from 'lucide-react'; 2 +import { Building2, Check, Mail, Map, MapPin, Mailbox, Trash2 } from 'lucide-react';
3 import { Input } from '../../ui/input'; 3 import { Input } from '../../ui/input';
4 import { Button } from '../../ui/button'; 4 import { Button } from '../../ui/button';
5 import { Label } from '../../ui/label'; 5 import { Label } from '../../ui/label';
@@ -26,7 +26,11 @@ import { @@ -26,7 +26,11 @@ import {
26 isBlankSpaceElement, 26 isBlankSpaceElement,
27 isCompanyAutoElement, 27 isCompanyAutoElement,
28 isTemplateSectionPersistedType, 28 isTemplateSectionPersistedType,
  29 + elementEditorDisplayName,
  30 + readElementPositionLocked,
29 NUTRITION_FIXED_ITEMS, 31 NUTRITION_FIXED_ITEMS,
  32 + LABEL_EDITOR_FONT_OPTIONS,
  33 + readLabelEditorFontFamilyChoice,
30 } from '../../../types/labelTemplate'; 34 } from '../../../types/labelTemplate';
31 import { 35 import {
32 buildCompanyPrintFieldsConfigPatch, 36 buildCompanyPrintFieldsConfigPatch,
@@ -36,6 +40,7 @@ import { @@ -36,6 +40,7 @@ import {
36 } from '../../../utils/companyPrintFields'; 40 } from '../../../utils/companyPrintFields';
37 import { cn } from '../../ui/utils'; 41 import { cn } from '../../ui/utils';
38 import { readInvertColors, isTextLikeElementForInvertColors } from '../../../utils/invertColorsConfig'; 42 import { readInvertColors, isTextLikeElementForInvertColors } from '../../../utils/invertColorsConfig';
  43 +import { readVerticalAlign, readElementRotation, readFontWeight, readFontStyle, readTextDecoration } from '../../../utils/textElementLayout';
39 import { 44 import {
40 type PreviewRulerDisplayUnit, 45 type PreviewRulerDisplayUnit,
41 displayLengthToElementPx, 46 displayLengthToElementPx,
@@ -45,10 +50,20 @@ import { @@ -45,10 +50,20 @@ import {
45 } from '../../../utils/previewRulerUnits'; 50 } from '../../../utils/previewRulerUnits';
46 import { unitToPx } from '../../../utils/labelTemplateUnits'; 51 import { unitToPx } from '../../../utils/labelTemplateUnits';
47 import { ImageUrlUpload } from '../../ui/image-url-upload'; 52 import { ImageUrlUpload } from '../../ui/image-url-upload';
  53 +import { readImageScaleMode } from '../../../utils/imageScaleMode';
  54 +import {
  55 + formatWeightDisplay,
  56 + readWeightInputMode,
  57 + WEIGHT_INPUT_MODE_OPTIONS,
  58 +} from '../../../utils/weightElement';
48 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption'; 59 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption';
49 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService'; 60 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
50 import { Checkbox } from '../../ui/checkbox'; 61 import { Checkbox } from '../../ui/checkbox';
51 -import { Trash2 } from 'lucide-react'; 62 +import {
  63 + NutritionManualEntryForm,
  64 + applyNutritionValuesToElementConfig,
  65 + nutritionValuesFromElementConfig,
  66 +} from '../NutritionManualEntryForm';
52 import { 67 import {
53 BARCODE_FORMAT_OPTIONS, 68 BARCODE_FORMAT_OPTIONS,
54 DEFAULT_BARCODE_FORMAT, 69 DEFAULT_BARCODE_FORMAT,
@@ -111,6 +126,11 @@ export function PropertiesPanel({ @@ -111,6 +126,11 @@ export function PropertiesPanel({
111 void readOnlyTemplateCode; 126 void readOnlyTemplateCode;
112 if (selectedElement) { 127 if (selectedElement) {
113 const isBlankElement = isBlankSpaceElement(selectedElement); 128 const isBlankElement = isBlankSpaceElement(selectedElement);
  129 + const elementDisplayName = elementEditorDisplayName(
  130 + selectedElement,
  131 + template.elements ?? [],
  132 + );
  133 + const positionLocked = readElementPositionLocked(selectedElement);
114 return ( 134 return (
115 <div className="flex h-full min-h-0 w-full min-w-0 flex-col border-l border-gray-200 bg-white"> 135 <div className="flex h-full min-h-0 w-full min-w-0 flex-col border-l border-gray-200 bg-white">
116 <div className="shrink-0 border-b border-gray-200 px-3 py-2 font-semibold text-gray-800"> 136 <div className="shrink-0 border-b border-gray-200 px-3 py-2 font-semibold text-gray-800">
@@ -124,6 +144,7 @@ export function PropertiesPanel({ @@ -124,6 +144,7 @@ export function PropertiesPanel({
124 <Input 144 <Input
125 type="number" 145 type="number"
126 value={selectedElement.x} 146 value={selectedElement.x}
  147 + disabled={positionLocked}
127 onChange={(e) => 148 onChange={(e) =>
128 onElementChange(selectedElement.id, { 149 onElementChange(selectedElement.id, {
129 x: Number(e.target.value) || 0, 150 x: Number(e.target.value) || 0,
@@ -137,6 +158,7 @@ export function PropertiesPanel({ @@ -137,6 +158,7 @@ export function PropertiesPanel({
137 <Input 158 <Input
138 type="number" 159 type="number"
139 value={selectedElement.y} 160 value={selectedElement.y}
  161 + disabled={positionLocked}
140 onChange={(e) => 162 onChange={(e) =>
141 onElementChange(selectedElement.id, { 163 onElementChange(selectedElement.id, {
142 y: Number(e.target.value) || 0, 164 y: Number(e.target.value) || 0,
@@ -146,6 +168,11 @@ export function PropertiesPanel({ @@ -146,6 +168,11 @@ export function PropertiesPanel({
146 /> 168 />
147 </div> 169 </div>
148 </div> 170 </div>
  171 + {positionLocked ? (
  172 + <p className="text-[10px] leading-snug text-amber-700">
  173 + Position is locked. Unlock in Available Elements to move or resize.
  174 + </p>
  175 + ) : null}
149 <div className="grid grid-cols-2 gap-2"> 176 <div className="grid grid-cols-2 gap-2">
150 <ElementDimensionField 177 <ElementDimensionField
151 label="Width" 178 label="Width"
@@ -153,6 +180,7 @@ export function PropertiesPanel({ @@ -153,6 +180,7 @@ export function PropertiesPanel({
153 paperSizeTemplate={template.width} 180 paperSizeTemplate={template.width}
154 templateUnit={template.unit} 181 templateUnit={template.unit}
155 displayUnit={previewRulerUnit} 182 displayUnit={previewRulerUnit}
  183 + disabled={positionLocked}
156 onPxChange={(width) => 184 onPxChange={(width) =>
157 onElementChange(selectedElement.id, { 185 onElementChange(selectedElement.id, {
158 width: Math.max(1, width), 186 width: Math.max(1, width),
@@ -165,6 +193,7 @@ export function PropertiesPanel({ @@ -165,6 +193,7 @@ export function PropertiesPanel({
165 paperSizeTemplate={template.height} 193 paperSizeTemplate={template.height}
166 templateUnit={template.unit} 194 templateUnit={template.unit}
167 displayUnit={previewRulerUnit} 195 displayUnit={previewRulerUnit}
  196 + disabled={positionLocked}
168 onPxChange={(height) => 197 onPxChange={(height) =>
169 onElementChange(selectedElement.id, { 198 onElementChange(selectedElement.id, {
170 height: Math.max(1, height), 199 height: Math.max(1, height),
@@ -181,9 +210,11 @@ export function PropertiesPanel({ @@ -181,9 +210,11 @@ export function PropertiesPanel({
181 <div> 210 <div>
182 <Label className="text-xs">Rotation</Label> 211 <Label className="text-xs">Rotation</Label>
183 <Select 212 <Select
184 - value={selectedElement.rotation} 213 + value={readElementRotation(selectedElement)}
185 onValueChange={(v: Rotation) => 214 onValueChange={(v: Rotation) =>
186 - onElementChange(selectedElement.id, { rotation: v }) 215 + onElementChange(selectedElement.id, {
  216 + rotation: v,
  217 + })
187 } 218 }
188 > 219 >
189 <SelectTrigger className="h-8 text-sm"> 220 <SelectTrigger className="h-8 text-sm">
@@ -230,18 +261,11 @@ export function PropertiesPanel({ @@ -230,18 +261,11 @@ export function PropertiesPanel({
230 <div> 261 <div>
231 <Label className="text-xs">Element name</Label> 262 <Label className="text-xs">Element name</Label>
232 <Input 263 <Input
233 - value={(selectedElement.elementName ?? "").trim()}  
234 - onChange={(e) =>  
235 - onElementChange(selectedElement.id, {  
236 - elementName: e.target.value,  
237 - })  
238 - }  
239 - className="h-8 text-sm mt-1" 264 + value={elementDisplayName}
  265 + readOnly
  266 + className="h-8 text-sm mt-1 bg-gray-50 text-gray-700 cursor-default"
240 placeholder="e.g. text1" 267 placeholder="e.g. text1"
241 /> 268 />
242 - <p className="text-[10px] text-gray-400 mt-1">  
243 - Required for save; used as data-entry column header (elementName).  
244 - </p>  
245 </div> 269 </div>
246 <ElementConfigFields 270 <ElementConfigFields
247 element={selectedElement} 271 element={selectedElement}
@@ -501,6 +525,7 @@ function ElementDimensionField({ @@ -501,6 +525,7 @@ function ElementDimensionField({
501 templateUnit, 525 templateUnit,
502 displayUnit, 526 displayUnit,
503 onPxChange, 527 onPxChange,
  528 + disabled = false,
504 }: { 529 }: {
505 label: string; 530 label: string;
506 pxValue: number; 531 pxValue: number;
@@ -508,6 +533,7 @@ function ElementDimensionField({ @@ -508,6 +533,7 @@ function ElementDimensionField({
508 templateUnit: Unit; 533 templateUnit: Unit;
509 displayUnit: PreviewRulerDisplayUnit; 534 displayUnit: PreviewRulerDisplayUnit;
510 onPxChange: (px: number) => void; 535 onPxChange: (px: number) => void;
  536 + disabled?: boolean;
511 }) { 537 }) {
512 const basePaperPx = unitToPx(Number(paperSizeTemplate) || 0, templateUnit); 538 const basePaperPx = unitToPx(Number(paperSizeTemplate) || 0, templateUnit);
513 const displayValue = elementPxToDisplayLength( 539 const displayValue = elementPxToDisplayLength(
@@ -527,6 +553,7 @@ function ElementDimensionField({ @@ -527,6 +553,7 @@ function ElementDimensionField({
527 type="number" 553 type="number"
528 step={displayUnit === 'mm' ? 1 : displayUnit === 'inch' ? 0.0001 : 0.001} 554 step={displayUnit === 'mm' ? 1 : displayUnit === 'inch' ? 0.0001 : 0.001}
529 value={formatPreviewRulerDisplayValue(displayValue, displayUnit)} 555 value={formatPreviewRulerDisplayValue(displayValue, displayUnit)}
  556 + disabled={disabled}
530 onChange={(e) => { 557 onChange={(e) => {
531 const nextDisplay = Number(e.target.value); 558 const nextDisplay = Number(e.target.value);
532 if (!Number.isFinite(nextDisplay)) return; 559 if (!Number.isFinite(nextDisplay)) return;
@@ -564,11 +591,124 @@ function InvertColorsField({ @@ -564,11 +591,124 @@ function InvertColorsField({
564 ); 591 );
565 } 592 }
566 593
  594 +function VerticalAlignField({
  595 + cfg,
  596 + update,
  597 +}: {
  598 + cfg: Record<string, unknown>;
  599 + update: (key: string, value: unknown) => void;
  600 +}) {
  601 + return (
  602 + <div>
  603 + <Label className="text-xs">Vertical Alignment</Label>
  604 + <Select
  605 + value={readVerticalAlign(cfg)}
  606 + onValueChange={(v) => update('verticalAlign', v)}
  607 + >
  608 + <SelectTrigger className="h-8 text-sm mt-1">
  609 + <SelectValue />
  610 + </SelectTrigger>
  611 + <SelectContent>
  612 + <SelectItem value="top">Top</SelectItem>
  613 + <SelectItem value="center">Center</SelectItem>
  614 + <SelectItem value="bottom">Bottom</SelectItem>
  615 + </SelectContent>
  616 + </Select>
  617 + </div>
  618 + );
  619 +}
  620 +
  621 +function FontFamilyField({
  622 + cfg,
  623 + update,
  624 +}: {
  625 + cfg: Record<string, unknown>;
  626 + update: (key: string, value: unknown) => void;
  627 +}) {
  628 + const current = readLabelEditorFontFamilyChoice(cfg);
  629 + return (
  630 + <div>
  631 + <Label className="text-xs">Font</Label>
  632 + <Select value={current} onValueChange={(v) => update('fontFamily', v)}>
  633 + <SelectTrigger
  634 + className="h-8 text-sm mt-1"
  635 + style={{ fontFamily: `'${current}', sans-serif` }}
  636 + >
  637 + <SelectValue />
  638 + </SelectTrigger>
  639 + <SelectContent>
  640 + {LABEL_EDITOR_FONT_OPTIONS.map((opt) => (
  641 + <SelectItem
  642 + key={opt.value}
  643 + value={opt.value}
  644 + style={{ fontFamily: `'${opt.value}', sans-serif` }}
  645 + >
  646 + {opt.label}
  647 + </SelectItem>
  648 + ))}
  649 + </SelectContent>
  650 + </Select>
  651 + </div>
  652 + );
  653 +}
  654 +
  655 +function BiuStyleFields({
  656 + cfg,
  657 + update,
  658 +}: {
  659 + cfg: Record<string, unknown>;
  660 + update: (key: string, value: unknown) => void;
  661 +}) {
  662 + const bold = readFontWeight(cfg) === 'bold';
  663 + const italic = readFontStyle(cfg) === 'italic';
  664 + const underline = readTextDecoration(cfg) === 'underline';
  665 + const btnBase =
  666 + 'h-8 w-8 p-0 text-xs font-semibold shrink-0';
  667 + return (
  668 + <div>
  669 + <Label className="text-xs">B.I.U</Label>
  670 + <div className="mt-1 flex items-center gap-1">
  671 + <Button
  672 + type="button"
  673 + variant={bold ? 'default' : 'outline'}
  674 + size="sm"
  675 + className={cn(btnBase, bold && 'bg-blue-600 hover:bg-blue-700')}
  676 + onClick={() => update('fontWeight', bold ? 'normal' : 'bold')}
  677 + title="Bold"
  678 + >
  679 + B
  680 + </Button>
  681 + <Button
  682 + type="button"
  683 + variant={italic ? 'default' : 'outline'}
  684 + size="sm"
  685 + className={cn(btnBase, italic && 'bg-blue-600 hover:bg-blue-700 italic')}
  686 + onClick={() => update('fontStyle', italic ? 'normal' : 'italic')}
  687 + title="Italic"
  688 + >
  689 + I
  690 + </Button>
  691 + <Button
  692 + type="button"
  693 + variant={underline ? 'default' : 'outline'}
  694 + size="sm"
  695 + className={cn(btnBase, underline && 'bg-blue-600 hover:bg-blue-700 underline')}
  696 + onClick={() => update('textDecoration', underline ? 'none' : 'underline')}
  697 + title="Underline"
  698 + >
  699 + U
  700 + </Button>
  701 + </div>
  702 + </div>
  703 + );
  704 +}
  705 +
567 function TextStaticStyleFields({ 706 function TextStaticStyleFields({
568 cfg, 707 cfg,
569 update, 708 update,
570 textAlignDefault, 709 textAlignDefault,
571 primaryTextLabel, 710 primaryTextLabel,
  711 + primaryTextHint,
572 hidePrimaryText = false, 712 hidePrimaryText = false,
573 }: { 713 }: {
574 cfg: Record<string, unknown>; 714 cfg: Record<string, unknown>;
@@ -576,6 +716,8 @@ function TextStaticStyleFields({ @@ -576,6 +716,8 @@ function TextStaticStyleFields({
576 textAlignDefault: string; 716 textAlignDefault: string;
577 /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */ 717 /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */
578 primaryTextLabel?: 'Text' | 'Value'; 718 primaryTextLabel?: 'Text' | 'Value';
  719 + /** Value/Text 输入框下方提示(如 Price 演示值说明) */
  720 + primaryTextHint?: string;
579 /** Auto-generated 控件:文案由系统自动填充,隐藏 Text/Value 输入 */ 721 /** Auto-generated 控件:文案由系统自动填充,隐藏 Text/Value 输入 */
580 hidePrimaryText?: boolean; 722 hidePrimaryText?: boolean;
581 }) { 723 }) {
@@ -590,8 +732,12 @@ function TextStaticStyleFields({ @@ -590,8 +732,12 @@ function TextStaticStyleFields({
590 onChange={(e) => update('text', e.target.value)} 732 onChange={(e) => update('text', e.target.value)}
591 className="h-8 text-sm mt-1" 733 className="h-8 text-sm mt-1"
592 /> 734 />
  735 + {primaryTextHint ? (
  736 + <p className="text-[10px] text-gray-400 mt-1">{primaryTextHint}</p>
  737 + ) : null}
593 </div> 738 </div>
594 ) : null} 739 ) : null}
  740 + <FontFamilyField cfg={cfg} update={update} />
595 <div> 741 <div>
596 <Label className="text-xs">Font Size</Label> 742 <Label className="text-xs">Font Size</Label>
597 <Input 743 <Input
@@ -617,6 +763,8 @@ function TextStaticStyleFields({ @@ -617,6 +763,8 @@ function TextStaticStyleFields({
617 </SelectContent> 763 </SelectContent>
618 </Select> 764 </Select>
619 </div> 765 </div>
  766 + <VerticalAlignField cfg={cfg} update={update} />
  767 + <BiuStyleFields cfg={cfg} update={update} />
620 </> 768 </>
621 ); 769 );
622 } 770 }
@@ -729,12 +877,22 @@ function ElementConfigFields({ @@ -729,12 +877,22 @@ function ElementConfigFields({
729 /> 877 />
730 ); 878 );
731 case 'TEXT_PRODUCT': 879 case 'TEXT_PRODUCT':
  880 + return (
  881 + <TextStaticStyleFields
  882 + cfg={cfg}
  883 + update={update}
  884 + textAlignDefault="right"
  885 + hidePrimaryText={hidePrimaryText}
  886 + />
  887 + );
732 case 'TEXT_PRICE': 888 case 'TEXT_PRICE':
733 return ( 889 return (
734 <TextStaticStyleFields 890 <TextStaticStyleFields
735 cfg={cfg} 891 cfg={cfg}
736 update={update} 892 update={update}
737 textAlignDefault="right" 893 textAlignDefault="right"
  894 + primaryTextLabel="Value"
  895 + primaryTextHint="Value entered here for demo only."
738 hidePrimaryText={hidePrimaryText} 896 hidePrimaryText={hidePrimaryText}
739 /> 897 />
740 ); 898 );
@@ -830,15 +988,13 @@ function ElementConfigFields({ @@ -830,15 +988,13 @@ function ElementConfigFields({
830 value={src} 988 value={src}
831 onChange={(url) => update('src', url)} 989 onChange={(url) => update('src', url)}
832 uploadSubDir="label-template-editor" 990 uploadSubDir="label-template-editor"
833 - oneImageOnly  
834 boxSizePx={TEMPLATE_IMAGE_UPLOAD_SIZE_PX} 991 boxSizePx={TEMPLATE_IMAGE_UPLOAD_SIZE_PX}
835 - hint="Stored in template; print uses this URL (empty if cleared)."  
836 /> 992 />
837 </div> 993 </div>
838 <div> 994 <div>
839 <Label className="text-xs">Scale Mode</Label> 995 <Label className="text-xs">Scale Mode</Label>
840 <Select 996 <Select
841 - value={(cfg.scaleMode as string) ?? 'contain'} 997 + value={readImageScaleMode(cfg)}
842 onValueChange={(v) => update('scaleMode', v)} 998 onValueChange={(v) => update('scaleMode', v)}
843 > 999 >
844 <SelectTrigger className="h-8 text-sm mt-1"> 1000 <SelectTrigger className="h-8 text-sm mt-1">
@@ -868,7 +1024,7 @@ function ElementConfigFields({ @@ -868,7 +1024,7 @@ function ElementConfigFields({
868 <div> 1024 <div>
869 <Label className="text-xs">Scale Mode</Label> 1025 <Label className="text-xs">Scale Mode</Label>
870 <Select 1026 <Select
871 - value={(cfg.scaleMode as string) ?? 'contain'} 1027 + value={readImageScaleMode(cfg)}
872 onValueChange={(v) => update('scaleMode', v)} 1028 onValueChange={(v) => update('scaleMode', v)}
873 > 1029 >
874 <SelectTrigger className="h-8 text-sm mt-1"> 1030 <SelectTrigger className="h-8 text-sm mt-1">
@@ -918,6 +1074,7 @@ function ElementConfigFields({ @@ -918,6 +1074,7 @@ function ElementConfigFields({
918 </p> 1074 </p>
919 ) : null} 1075 ) : null}
920 </div> 1076 </div>
  1077 + <FontFamilyField cfg={cfg} update={update} />
921 <div> 1078 <div>
922 <Label className="text-xs">Font Size</Label> 1079 <Label className="text-xs">Font Size</Label>
923 <Input 1080 <Input
@@ -943,6 +1100,8 @@ function ElementConfigFields({ @@ -943,6 +1100,8 @@ function ElementConfigFields({
943 </SelectContent> 1100 </SelectContent>
944 </Select> 1101 </Select>
945 </div> 1102 </div>
  1103 + <VerticalAlignField cfg={cfg} update={update} />
  1104 + <BiuStyleFields cfg={cfg} update={update} />
946 </> 1105 </>
947 ); 1106 );
948 } 1107 }
@@ -953,6 +1112,7 @@ function ElementConfigFields({ @@ -953,6 +1112,7 @@ function ElementConfigFields({
953 <Label className="text-xs">Format</Label> 1112 <Label className="text-xs">Format</Label>
954 <Input value="HH:mm" className="h-8 text-sm mt-1" readOnly /> 1113 <Input value="HH:mm" className="h-8 text-sm mt-1" readOnly />
955 </div> 1114 </div>
  1115 + <FontFamilyField cfg={cfg} update={update} />
956 <div> 1116 <div>
957 <Label className="text-xs">Font Size</Label> 1117 <Label className="text-xs">Font Size</Label>
958 <Input 1118 <Input
@@ -978,6 +1138,8 @@ function ElementConfigFields({ @@ -978,6 +1138,8 @@ function ElementConfigFields({
978 </SelectContent> 1138 </SelectContent>
979 </Select> 1139 </Select>
980 </div> 1140 </div>
  1141 + <VerticalAlignField cfg={cfg} update={update} />
  1142 + <BiuStyleFields cfg={cfg} update={update} />
981 </> 1143 </>
982 ); 1144 );
983 case 'DURATION': 1145 case 'DURATION':
@@ -1001,6 +1163,7 @@ function ElementConfigFields({ @@ -1001,6 +1163,7 @@ function ElementConfigFields({
1001 </SelectContent> 1163 </SelectContent>
1002 </Select> 1164 </Select>
1003 </div> 1165 </div>
  1166 + <FontFamilyField cfg={cfg} update={update} />
1004 <div> 1167 <div>
1005 <Label className="text-xs">Font Size</Label> 1168 <Label className="text-xs">Font Size</Label>
1006 <Input 1169 <Input
@@ -1026,6 +1189,8 @@ function ElementConfigFields({ @@ -1026,6 +1189,8 @@ function ElementConfigFields({
1026 </SelectContent> 1189 </SelectContent>
1027 </Select> 1190 </Select>
1028 </div> 1191 </div>
  1192 + <VerticalAlignField cfg={cfg} update={update} />
  1193 + <BiuStyleFields cfg={cfg} update={update} />
1029 </> 1194 </>
1030 ); 1195 );
1031 case 'WEIGHT': 1196 case 'WEIGHT':
@@ -1033,9 +1198,28 @@ function ElementConfigFields({ @@ -1033,9 +1198,28 @@ function ElementConfigFields({
1033 const weightUnit = normalizeWeightUnit(cfgPickStr(cfg, ['unit', 'Unit'], 'g')); 1198 const weightUnit = normalizeWeightUnit(cfgPickStr(cfg, ['unit', 'Unit'], 'g'));
1034 const textAlign = cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left'); 1199 const textAlign = cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left');
1035 const fontSize = cfgPickNum(cfg, ['fontSize', 'FontSize'], 14); 1200 const fontSize = cfgPickNum(cfg, ['fontSize', 'FontSize'], 14);
  1201 + const weightInputMode = readWeightInputMode(cfg);
1036 return ( 1202 return (
1037 <> 1203 <>
1038 <div> 1204 <div>
  1205 + <Label className="text-xs">Weight input mode</Label>
  1206 + <Select
  1207 + value={weightInputMode}
  1208 + onValueChange={(v) => update('weightInputMode', v)}
  1209 + >
  1210 + <SelectTrigger className="h-8 text-sm mt-1">
  1211 + <SelectValue />
  1212 + </SelectTrigger>
  1213 + <SelectContent>
  1214 + {WEIGHT_INPUT_MODE_OPTIONS.map((item) => (
  1215 + <SelectItem key={item.value} value={item.value}>
  1216 + {item.label}
  1217 + </SelectItem>
  1218 + ))}
  1219 + </SelectContent>
  1220 + </Select>
  1221 + </div>
  1222 + <div>
1039 <Label className="text-xs">Value</Label> 1223 <Label className="text-xs">Value</Label>
1040 <Input 1224 <Input
1041 type="number" 1225 type="number"
@@ -1043,6 +1227,9 @@ function ElementConfigFields({ @@ -1043,6 +1227,9 @@ function ElementConfigFields({
1043 onChange={(e) => update('value', Number(e.target.value) || 0)} 1227 onChange={(e) => update('value', Number(e.target.value) || 0)}
1044 className="h-8 text-sm mt-1" 1228 className="h-8 text-sm mt-1"
1045 /> 1229 />
  1230 + <p className="text-[10px] text-gray-400 mt-1">
  1231 + Demo value for editor preview only. App users enter weight at print time.
  1232 + </p>
1046 </div> 1233 </div>
1047 <div> 1234 <div>
1048 <Label className="text-xs">Unit</Label> 1235 <Label className="text-xs">Unit</Label>
@@ -1062,6 +1249,7 @@ function ElementConfigFields({ @@ -1062,6 +1249,7 @@ function ElementConfigFields({
1062 </SelectContent> 1249 </SelectContent>
1063 </Select> 1250 </Select>
1064 </div> 1251 </div>
  1252 + <FontFamilyField cfg={cfg} update={update} />
1065 <div> 1253 <div>
1066 <Label className="text-xs">Font Size</Label> 1254 <Label className="text-xs">Font Size</Label>
1067 <Input 1255 <Input
@@ -1087,6 +1275,8 @@ function ElementConfigFields({ @@ -1087,6 +1275,8 @@ function ElementConfigFields({
1087 </SelectContent> 1275 </SelectContent>
1088 </Select> 1276 </Select>
1089 </div> 1277 </div>
  1278 + <VerticalAlignField cfg={cfg} update={update} />
  1279 + <BiuStyleFields cfg={cfg} update={update} />
1090 </> 1280 </>
1091 ); 1281 );
1092 } 1282 }
@@ -1125,27 +1315,73 @@ function ElementConfigFields({ @@ -1125,27 +1315,73 @@ function ElementConfigFields({
1125 case 'NUTRITION': 1315 case 'NUTRITION':
1126 { 1316 {
1127 const extraRows = nutritionExtraRows(cfg); 1317 const extraRows = nutritionExtraRows(cfg);
  1318 + const readLessThan = (key: string): boolean => {
  1319 + if (key === 'calories') return Boolean(cfg.caloriesLessThan);
  1320 + const baseFixed = Array.isArray(cfg.fixedNutrients)
  1321 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  1322 + : [];
  1323 + const row = baseFixed.find((r) => String(r.key ?? '').trim() === key);
  1324 + return Boolean(row?.lessThan ?? cfg[`${key}LessThan`]);
  1325 + };
  1326 + const setLessThan = (key: string, lessThan: boolean) => {
  1327 + if (key === 'calories') {
  1328 + onChange({ ...cfg, caloriesLessThan: lessThan });
  1329 + return;
  1330 + }
  1331 + const baseFixed = Array.isArray(cfg.fixedNutrients)
  1332 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  1333 + : [];
  1334 + const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => {
  1335 + const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === item.key);
  1336 + const unit =
  1337 + nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '');
  1338 + const nextLessThan = item.key === key ? lessThan : Boolean(baseRow?.lessThan ?? cfg[`${item.key}LessThan`]);
  1339 + return {
  1340 + key: item.key,
  1341 + label: item.label,
  1342 + value: String(baseRow?.value ?? cfg[item.key] ?? ''),
  1343 + unit,
  1344 + dailyValuePercent: String(
  1345 + baseRow?.dailyValuePercent ?? baseRow?.percent ?? cfg[`${item.key}Percent`] ?? '',
  1346 + ),
  1347 + lessThan: nextLessThan,
  1348 + };
  1349 + });
  1350 + const patch: Record<string, unknown> = { fixedNutrients: fixedRows, [`${key}LessThan`]: lessThan };
  1351 + for (const item of NUTRITION_FIXED_ITEMS) {
  1352 + const row = fixedRows.find((r) => r.key === item.key);
  1353 + patch[`${item.key}LessThan`] = Boolean(row?.lessThan);
  1354 + }
  1355 + onChange({ ...cfg, ...patch });
  1356 + };
1128 const applyFixedNutrientUnit = (key: string, nextUnit: string) => { 1357 const applyFixedNutrientUnit = (key: string, nextUnit: string) => {
  1358 + const baseFixed = Array.isArray(cfg.fixedNutrients)
  1359 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  1360 + : [];
  1361 + const previewValues = nutritionValuesFromElementConfig(cfg);
1129 const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => { 1362 const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => {
  1363 + const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === item.key);
1130 const unit = 1364 const unit =
1131 nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? ''); 1365 nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '');
1132 return { 1366 return {
1133 key: item.key, 1367 key: item.key,
1134 label: item.label, 1368 label: item.label,
1135 - value: '', 1369 + value: String(baseRow?.value ?? cfg[item.key] ?? ''),
1136 unit: item.key === key ? nextUnit : unit, 1370 unit: item.key === key ? nextUnit : unit,
  1371 + dailyValuePercent: String(
  1372 + baseRow?.dailyValuePercent ?? baseRow?.percent ?? cfg[`${item.key}Percent`] ?? '',
  1373 + ),
  1374 + lessThan: Boolean(baseRow?.lessThan ?? cfg[`${item.key}LessThan`]),
1137 }; 1375 };
1138 }); 1376 });
1139 const keyPatch: Record<string, unknown> = { fixedNutrients: fixedRows }; 1377 const keyPatch: Record<string, unknown> = { fixedNutrients: fixedRows };
1140 for (const item of NUTRITION_FIXED_ITEMS) { 1378 for (const item of NUTRITION_FIXED_ITEMS) {
1141 - keyPatch[item.key] = '';  
1142 const row = fixedRows.find((r) => r.key === item.key); 1379 const row = fixedRows.find((r) => r.key === item.key);
  1380 + if (row?.value) keyPatch[item.key] = row.value;
  1381 + else keyPatch[item.key] = '';
1143 if (row?.unit) keyPatch[`${item.key}Unit`] = row.unit; 1382 if (row?.unit) keyPatch[`${item.key}Unit`] = row.unit;
1144 } 1383 }
1145 - keyPatch.calories = '';  
1146 - keyPatch.servingsPerContainer = '';  
1147 - keyPatch.servingSize = '';  
1148 - onChange(keyPatch); 1384 + onChange(applyNutritionValuesToElementConfig({ ...cfg, ...keyPatch }, previewValues));
1149 }; 1385 };
1150 1386
1151 const addExtraNutrient = () => { 1387 const addExtraNutrient = () => {
@@ -1196,20 +1432,50 @@ function ElementConfigFields({ @@ -1196,20 +1432,50 @@ function ElementConfigFields({
1196 /> 1432 />
1197 </div> 1433 </div>
1198 </div> 1434 </div>
1199 - <p className="text-[10px] text-gray-400 mt-2">  
1200 - Servings, calories and nutrient values are entered when creating labels or in Bulk Add.  
1201 - </p> 1435 + {element.height < 240 ? (
  1436 + <p className="text-[10px] text-amber-600 mt-1">
  1437 + Tip: increase element height to at least 280px to show the full Nutrition Facts panel.
  1438 + </p>
  1439 + ) : null}
  1440 + </div>
  1441 + <div className="rounded-md border border-gray-200 bg-gray-50/80 p-2.5">
  1442 + <Label className="text-xs font-semibold">Preview / sample data</Label>
  1443 + <NutritionManualEntryForm
  1444 + element={element}
  1445 + values={nutritionValuesFromElementConfig(cfg)}
  1446 + onFieldChange={(subKey, next) => {
  1447 + const merged = applyNutritionValuesToElementConfig(cfg, {
  1448 + ...nutritionValuesFromElementConfig(cfg),
  1449 + [subKey]: next,
  1450 + });
  1451 + onChange(merged);
  1452 + }}
  1453 + className="space-y-3 mt-2"
  1454 + />
1202 </div> 1455 </div>
1203 <div> 1456 <div>
1204 <Label className="text-xs">Nutrition table structure</Label> 1457 <Label className="text-xs">Nutrition table structure</Label>
1205 <div className="space-y-1.5 mt-1"> 1458 <div className="space-y-1.5 mt-1">
1206 - <div className="grid grid-cols-[1fr_58px_26px] gap-1.5 items-center text-[10px] text-gray-500 px-0.5"> 1459 + <div className="grid grid-cols-[1fr_58px_26px_26px] gap-1.5 items-center text-[10px] text-gray-500 px-0.5">
1207 <span>Name</span> 1460 <span>Name</span>
1208 <span>Unit</span> 1461 <span>Unit</span>
  1462 + <span className="text-center">&lt;</span>
  1463 + <span />
  1464 + </div>
  1465 + <div className="grid grid-cols-[1fr_58px_26px_26px] gap-1.5 items-center">
  1466 + <span className="text-xs text-gray-600">Calories</span>
  1467 + <span />
  1468 + <div className="flex justify-center">
  1469 + <Checkbox
  1470 + checked={readLessThan('calories')}
  1471 + onCheckedChange={(v) => setLessThan('calories', v === true)}
  1472 + aria-label="Calories less-than prefix"
  1473 + />
  1474 + </div>
1209 <span /> 1475 <span />
1210 </div> 1476 </div>
1211 {NUTRITION_FIXED_ITEMS.map((item) => ( 1477 {NUTRITION_FIXED_ITEMS.map((item) => (
1212 - <div key={item.key} className="grid grid-cols-[1fr_58px_26px] gap-1.5 items-center"> 1478 + <div key={item.key} className="grid grid-cols-[1fr_58px_26px_26px] gap-1.5 items-center">
1213 <span className="text-xs text-gray-600">{item.label}</span> 1479 <span className="text-xs text-gray-600">{item.label}</span>
1214 <Input 1480 <Input
1215 value={nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '')} 1481 value={nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '')}
@@ -1217,11 +1483,18 @@ function ElementConfigFields({ @@ -1217,11 +1483,18 @@ function ElementConfigFields({
1217 className="h-8 text-sm" 1483 className="h-8 text-sm"
1218 placeholder="Unit" 1484 placeholder="Unit"
1219 /> 1485 />
  1486 + <div className="flex justify-center">
  1487 + <Checkbox
  1488 + checked={readLessThan(item.key)}
  1489 + onCheckedChange={(v) => setLessThan(item.key, v === true)}
  1490 + aria-label={`${item.label} less-than prefix`}
  1491 + />
  1492 + </div>
1220 <span /> 1493 <span />
1221 </div> 1494 </div>
1222 ))} 1495 ))}
1223 {extraRows.map((row) => ( 1496 {extraRows.map((row) => (
1224 - <div key={row.id} className="grid grid-cols-[1fr_58px_26px] gap-1.5 items-center"> 1497 + <div key={row.id} className="grid grid-cols-[1fr_58px_26px_26px] gap-1.5 items-center">
1225 <Input 1498 <Input
1226 value={row.name} 1499 value={row.name}
1227 onChange={(e) => updateExtraNutrient(row.id, 'name', e.target.value)} 1500 onChange={(e) => updateExtraNutrient(row.id, 'name', e.target.value)}
@@ -1234,6 +1507,15 @@ function ElementConfigFields({ @@ -1234,6 +1507,15 @@ function ElementConfigFields({
1234 className="h-8 text-sm" 1507 className="h-8 text-sm"
1235 placeholder="Unit" 1508 placeholder="Unit"
1236 /> 1509 />
  1510 + <div className="flex justify-center">
  1511 + <Checkbox
  1512 + checked={Boolean(cfg[`extra:${row.id}:lessThan`])}
  1513 + onCheckedChange={(v) =>
  1514 + onChange({ ...cfg, [`extra:${row.id}:lessThan`]: v === true })
  1515 + }
  1516 + aria-label={`${row.name || 'Extra nutrient'} less-than prefix`}
  1517 + />
  1518 + </div>
1237 <Button 1519 <Button
1238 type="button" 1520 type="button"
1239 variant="ghost" 1521 variant="ghost"
@@ -1255,7 +1537,7 @@ function ElementConfigFields({ @@ -1255,7 +1537,7 @@ function ElementConfigFields({
1255 Add Nutrient 1537 Add Nutrient
1256 </Button> 1538 </Button>
1257 <div className="text-[10px] text-gray-400 mt-2"> 1539 <div className="text-[10px] text-gray-400 mt-2">
1258 - Unit is appended after the value when printing. 1540 + Reference unit only (not auto-appended). &lt; prefix applies when enabled here; label data only enters amount and %DV.
1259 </div> 1541 </div>
1260 </div> 1542 </div>
1261 </> 1543 </>
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/available-elements-panel.tsx 0 → 100644
  1 +import React from 'react';
  2 +import { Lock, Unlock, X } from 'lucide-react';
  3 +import { cn } from '../../ui/utils';
  4 +import type { LabelElement } from '../../../types/labelTemplate';
  5 +import {
  6 + elementEditorDisplayName,
  7 + readElementPositionLocked,
  8 + sortTemplateElementsForDisplay,
  9 +} from '../../../types/labelTemplate';
  10 +
  11 +interface AvailableElementsPanelProps {
  12 + elements: LabelElement[];
  13 + selectedId: string | null;
  14 + onSelect: (id: string) => void;
  15 + onTogglePositionLock: (id: string, locked: boolean) => void;
  16 + onClose: () => void;
  17 +}
  18 +
  19 +export function AvailableElementsPanel({
  20 + elements,
  21 + selectedId,
  22 + onSelect,
  23 + onTogglePositionLock,
  24 + onClose,
  25 +}: AvailableElementsPanelProps) {
  26 + const sorted = sortTemplateElementsForDisplay(elements);
  27 +
  28 + return (
  29 + <div className="absolute left-0 top-0 z-20 flex h-full w-56 flex-col border-r border-gray-200 bg-white shadow-lg">
  30 + <div className="flex shrink-0 items-center justify-between border-b border-gray-200 px-3 py-2">
  31 + <h3 className="text-sm font-semibold text-gray-800">Available Elements</h3>
  32 + <button
  33 + type="button"
  34 + onClick={onClose}
  35 + className="rounded p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-800"
  36 + title="Close"
  37 + aria-label="Close Available Elements"
  38 + >
  39 + <X className="h-4 w-4" />
  40 + </button>
  41 + </div>
  42 + <div className="min-h-0 flex-1 overflow-y-auto overscroll-contain py-1">
  43 + {sorted.length === 0 ? (
  44 + <p className="px-3 py-4 text-xs text-gray-500">No elements on this label yet.</p>
  45 + ) : (
  46 + <ul className="space-y-0.5 px-1">
  47 + {sorted.map((el) => {
  48 + const locked = readElementPositionLocked(el);
  49 + const isSelected = selectedId === el.id;
  50 + const label = elementEditorDisplayName(el, elements);
  51 + return (
  52 + <li key={el.id}>
  53 + <div
  54 + className={cn(
  55 + 'flex items-center gap-1 rounded-md pr-1 transition-colors',
  56 + isSelected ? 'bg-blue-50' : 'hover:bg-gray-50',
  57 + )}
  58 + >
  59 + <button
  60 + type="button"
  61 + onClick={() => onSelect(el.id)}
  62 + className={cn(
  63 + 'min-w-0 flex-1 truncate px-2 py-2 text-left text-xs',
  64 + isSelected ? 'font-medium text-blue-900' : 'text-gray-800',
  65 + )}
  66 + title={label}
  67 + >
  68 + {label}
  69 + </button>
  70 + <button
  71 + type="button"
  72 + onClick={(e) => {
  73 + e.stopPropagation();
  74 + onTogglePositionLock(el.id, !locked);
  75 + }}
  76 + className={cn(
  77 + 'shrink-0 rounded p-1.5 transition-colors',
  78 + locked
  79 + ? 'text-gray-700 hover:bg-gray-200'
  80 + : 'text-gray-400 hover:bg-gray-100 hover:text-gray-700',
  81 + )}
  82 + title={locked ? 'Unlock position' : 'Lock position'}
  83 + aria-label={locked ? 'Unlock position' : 'Lock position'}
  84 + >
  85 + {locked ? (
  86 + <Lock className="h-3.5 w-3.5" />
  87 + ) : (
  88 + <Unlock className="h-3.5 w-3.5" />
  89 + )}
  90 + </button>
  91 + </div>
  92 + </li>
  93 + );
  94 + })}
  95 + </ul>
  96 + )}
  97 + </div>
  98 + </div>
  99 + );
  100 +}
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/elements-panel-instruction-page.tsx 0 → 100644
  1 +import React from "react";
  2 +import { ArrowLeft, Info } from "lucide-react";
  3 +import { Button } from "../../ui/button";
  4 +
  5 +export const ELEMENT_LIBRARY_INSTRUCTION_SECTIONS = [
  6 + {
  7 + title: "Entered on This Template",
  8 + body:
  9 + "When you add these items to your template, the text will be the same on all labels that use this template.",
  10 + },
  11 + {
  12 + title: "Entered on Each Label",
  13 + body:
  14 + "When you add these items to your template, the text will be different on each of the labels that use this template. For example, if you use Label Name as one of your items, one label may have the text 'Chicken' and another 'Pork.'",
  15 + },
  16 + {
  17 + title: "Automatic",
  18 + body:
  19 + "When you add these items to your template, the printed text will be automatically changed according to various factors (location, employee, date, time, product, etc.).",
  20 + },
  21 + {
  22 + title: "Entered When Printing Label",
  23 + body:
  24 + "The person printing the label will use these items to add information to the label at the time of printing.",
  25 + },
  26 +] as const;
  27 +
  28 +export const ELEMENT_LIBRARY_INSTRUCTION_INTRO =
  29 + "Click on an item above to add it to your template. Once an item has been placed into your template, there may be additional options for you to select.";
  30 +
  31 +interface ElementsPanelInstructionPageProps {
  32 + onClose: () => void;
  33 +}
  34 +
  35 +/** 元素库说明:独立全屏页,由左侧 Instruction 链接进入 */
  36 +export function ElementsPanelInstructionPage({ onClose }: ElementsPanelInstructionPageProps) {
  37 + return (
  38 + <div
  39 + className="flex min-h-0 flex-1 flex-col overflow-hidden bg-[#eef3fb]"
  40 + style={{ flex: "1 1 0%", minHeight: 0 }}
  41 + >
  42 + <div className="flex shrink-0 items-center gap-2 border-b border-[#cfd9ea] bg-white px-4 py-2">
  43 + <Button
  44 + type="button"
  45 + size="sm"
  46 + variant="outline"
  47 + className="h-8 shrink-0 text-xs"
  48 + onClick={onClose}
  49 + >
  50 + <ArrowLeft className="mr-1 h-3.5 w-3.5" />
  51 + Back to editor
  52 + </Button>
  53 + <h1 className="text-sm font-semibold text-gray-800">Instruction</h1>
  54 + </div>
  55 + <div className="min-h-0 flex-1 overflow-y-auto p-4">
  56 + <div className="mx-auto max-w-2xl rounded-lg border border-[#c2d1e8] bg-white p-4 shadow-sm">
  57 + <div className="flex gap-3">
  58 + <Info className="mt-0.5 h-5 w-5 shrink-0 text-blue-600" aria-hidden />
  59 + <div className="min-w-0 space-y-4 text-sm leading-relaxed">
  60 + <p className="text-gray-700">{ELEMENT_LIBRARY_INSTRUCTION_INTRO}</p>
  61 + {ELEMENT_LIBRARY_INSTRUCTION_SECTIONS.map((section) => (
  62 + <div key={section.title}>
  63 + <p className="text-base font-bold text-gray-900 underline underline-offset-2">
  64 + {section.title}
  65 + </p>
  66 + <p className="mt-1.5 text-sm font-normal text-gray-600">{section.body}</p>
  67 + </div>
  68 + ))}
  69 + </div>
  70 + </div>
  71 + </div>
  72 + </div>
  73 + </div>
  74 + );
  75 +}
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/index.tsx
@@ -35,7 +35,21 @@ import { @@ -35,7 +35,21 @@ import {
35 valueSourceTypeForLibraryCategory, 35 valueSourceTypeForLibraryCategory,
36 } from '../../../types/labelTemplate'; 36 } from '../../../types/labelTemplate';
37 import { ElementsPanel } from './ElementsPanel'; 37 import { ElementsPanel } from './ElementsPanel';
38 -import { LabelCanvas, LabelPreviewOnly, clampLabelElementBox, mergeLabelElementLivePatch, mergeTemplateElementsLivePatch, type LabelElementLivePatch } from './LabelCanvas'; 38 +import { ElementsPanelInstructionPage } from './elements-panel-instruction-page';
  39 +import {
  40 + normalizeElementRotationBox,
  41 + patchElementRotationWithLayout,
  42 + readElementRotation,
  43 + canonicalElementGeometry,
  44 +} from '../../../utils/textElementLayout';
  45 +import {
  46 + LabelCanvas,
  47 + LabelPreviewOnly,
  48 + clampLabelElementBox,
  49 + mergeLabelElementLivePatch,
  50 + mergeTemplateElementsLivePatch,
  51 + type LabelElementLivePatch,
  52 +} from './LabelCanvas';
39 import type { PreviewRulerDisplayUnit } from '@/utils/previewRulerUnits'; 53 import type { PreviewRulerDisplayUnit } from '@/utils/previewRulerUnits';
40 import { PropertiesPanel } from './PropertiesPanel'; 54 import { PropertiesPanel } from './PropertiesPanel';
41 import { createLabelTemplate, getLabelTemplate, getLabelTemplates, updateLabelTemplate } from '../../../services/labelTemplateService'; 55 import { createLabelTemplate, getLabelTemplate, getLabelTemplates, updateLabelTemplate } from '../../../services/labelTemplateService';
@@ -72,6 +86,12 @@ function buildCopiedTemplateId(sourceId: string): string { @@ -72,6 +86,12 @@ function buildCopiedTemplateId(sourceId: string): string {
72 return `tpl_${seed}_${Date.now().toString(36)}`; 86 return `tpl_${seed}_${Date.now().toString(36)}`;
73 } 87 }
74 88
  89 +function normalizeEditorElements(elements: LabelElement[]): LabelElement[] {
  90 + return sanitizeNutritionElementsForTemplateEditor(elements).map((el) =>
  91 + normalizeElementRotationBox(el),
  92 + );
  93 +}
  94 +
75 function cloneStarterTemplate(source: LabelTemplate): LabelTemplate { 95 function cloneStarterTemplate(source: LabelTemplate): LabelTemplate {
76 return { 96 return {
77 ...source, 97 ...source,
@@ -103,7 +123,7 @@ export function LabelTemplateEditor({ @@ -103,7 +123,7 @@ export function LabelTemplateEditor({
103 if (initialTemplate) { 123 if (initialTemplate) {
104 return { 124 return {
105 ...initialTemplate, 125 ...initialTemplate,
106 - elements: sanitizeNutritionElementsForTemplateEditor(initialTemplate.elements), 126 + elements: normalizeEditorElements(initialTemplate.elements),
107 }; 127 };
108 } 128 }
109 const next = createDefaultTemplate(templateId ?? undefined); 129 const next = createDefaultTemplate(templateId ?? undefined);
@@ -118,6 +138,7 @@ export function LabelTemplateEditor({ @@ -118,6 +138,7 @@ export function LabelTemplateEditor({
118 liveElementPatchRef.current = liveElementPatch; 138 liveElementPatchRef.current = liveElementPatch;
119 const [scale, setScale] = useState(DEFAULT_SCALE); 139 const [scale, setScale] = useState(DEFAULT_SCALE);
120 const [previewOpen, setPreviewOpen] = useState(false); 140 const [previewOpen, setPreviewOpen] = useState(false);
  141 + const [instructionOpen, setInstructionOpen] = useState(false);
121 const [previewRulerUnit, setPreviewRulerUnit] = useState<PreviewRulerDisplayUnit>('cm'); 142 const [previewRulerUnit, setPreviewRulerUnit] = useState<PreviewRulerDisplayUnit>('cm');
122 const [starterOptions, setStarterOptions] = useState<Array<{ code: string; name: string }>>([]); 143 const [starterOptions, setStarterOptions] = useState<Array<{ code: string; name: string }>>([]);
123 const [selectedStarterCode, setSelectedStarterCode] = useState<string>(''); 144 const [selectedStarterCode, setSelectedStarterCode] = useState<string>('');
@@ -138,6 +159,26 @@ export function LabelTemplateEditor({ @@ -138,6 +159,26 @@ export function LabelTemplateEditor({
138 return mergeLabelElementLivePatch(el, liveElementPatch); 159 return mergeLabelElementLivePatch(el, liveElementPatch);
139 }, [template.elements, selectedId, liveElementPatch]); 160 }, [template.elements, selectedId, liveElementPatch]);
140 161
  162 + /** 打开编辑器时纠正 vertical 但宽>高的历史数据 */
  163 + useEffect(() => {
  164 + setTemplate((prev) => {
  165 + let changed = false;
  166 + const elements = prev.elements.map((el) => {
  167 + const fixed = normalizeElementRotationBox(el);
  168 + if (
  169 + fixed.x !== el.x ||
  170 + fixed.y !== el.y ||
  171 + fixed.width !== el.width ||
  172 + fixed.height !== el.height
  173 + ) {
  174 + changed = true;
  175 + }
  176 + return fixed;
  177 + });
  178 + return changed ? { ...prev, elements } : prev;
  179 + });
  180 + }, []);
  181 +
141 const previewTemplate = useMemo( 182 const previewTemplate = useMemo(
142 () => ({ 183 () => ({
143 ...template, 184 ...template,
@@ -308,7 +349,7 @@ export function LabelTemplateEditor({ @@ -308,7 +349,7 @@ export function LabelTemplateEditor({
308 printOrientation: normalizePrintOrientation( 349 printOrientation: normalizePrintOrientation(
309 apiTemplate.printOrientation ?? (apiTemplate as Record<string, unknown>).PrintOrientation, 350 apiTemplate.printOrientation ?? (apiTemplate as Record<string, unknown>).PrintOrientation,
310 ), 351 ),
311 - elements: sanitizeNutritionElementsForTemplateEditor( 352 + elements: normalizeEditorElements(
312 (apiTemplate.elements ?? []).map((raw, idx) => { 353 (apiTemplate.elements ?? []).map((raw, idx) => {
313 const el = raw as LabelElement; 354 const el = raw as LabelElement;
314 const en = (el.elementName ?? '').trim(); 355 const en = (el.elementName ?? '').trim();
@@ -370,13 +411,26 @@ export function LabelTemplateEditor({ @@ -370,13 +411,26 @@ export function LabelTemplateEditor({
370 ...prev, 411 ...prev,
371 elements: prev.elements.map((el) => { 412 elements: prev.elements.map((el) => {
372 if (el.id !== id) return el; 413 if (el.id !== id) return el;
373 - const merged = { ...el, ...patch }; 414 + let merged = { ...el, ...patch };
  415 + if (patch.rotation !== undefined) {
  416 + const nextRot = readElementRotation({ rotation: patch.rotation });
  417 + merged = {
  418 + ...merged,
  419 + ...patchElementRotationWithLayout(el, nextRot),
  420 + rotation: nextRot,
  421 + };
  422 + }
374 const geomTouched = 423 const geomTouched =
375 patch.x !== undefined || 424 patch.x !== undefined ||
376 patch.y !== undefined || 425 patch.y !== undefined ||
377 patch.width !== undefined || 426 patch.width !== undefined ||
378 - patch.height !== undefined; 427 + patch.height !== undefined ||
  428 + patch.rotation !== undefined;
379 if (!geomTouched) return merged; 429 if (!geomTouched) return merged;
  430 + // 竖排且宽>高:先纠正宽高再 clamp,避免 x 被钳到最左边
  431 + if (readElementRotation(merged) === 'vertical' && merged.width > merged.height) {
  432 + merged = canonicalElementGeometry(merged);
  433 + }
380 const c = clampLabelElementBox( 434 const c = clampLabelElementBox(
381 merged.x, 435 merged.x,
382 merged.y, 436 merged.y,
@@ -387,7 +441,13 @@ export function LabelTemplateEditor({ @@ -387,7 +441,13 @@ export function LabelTemplateEditor({
387 undefined, 441 undefined,
388 orientation, 442 orientation,
389 ); 443 );
390 - return { ...merged, x: c.x, y: c.y, width: c.w, height: c.h }; 444 + return {
  445 + ...merged,
  446 + x: c.x,
  447 + y: c.y,
  448 + width: c.w,
  449 + height: c.h,
  450 + };
391 }), 451 }),
392 }; 452 };
393 }); 453 });
@@ -396,17 +456,16 @@ export function LabelTemplateEditor({ @@ -396,17 +456,16 @@ export function LabelTemplateEditor({
396 const flushLiveElementPatch = useCallback(() => { 456 const flushLiveElementPatch = useCallback(() => {
397 const patch = liveElementPatchRef.current; 457 const patch = liveElementPatchRef.current;
398 if (!patch?.id) return; 458 if (!patch?.id) return;
399 - const { id, x, y, width, height } = patch;  
400 - const geom: Partial<LabelElement> = {};  
401 - if (x !== undefined) geom.x = x;  
402 - if (y !== undefined) geom.y = y;  
403 - if (width !== undefined) geom.width = width;  
404 - if (height !== undefined) geom.height = height;  
405 - if (Object.keys(geom).length > 0) {  
406 - updateElement(id, geom);  
407 - } 459 + const el = template.elements.find((e) => e.id === patch.id);
  460 + if (!el) return;
  461 + updateElement(patch.id, {
  462 + x: patch.x ?? el.x,
  463 + y: patch.y ?? el.y,
  464 + width: patch.width ?? el.width,
  465 + height: patch.height ?? el.height,
  466 + });
408 setLiveElementPatch(null); 467 setLiveElementPatch(null);
409 - }, [updateElement]); 468 + }, [template.elements, updateElement]);
410 469
411 const handleSelectElement = useCallback((id: string | null) => { 470 const handleSelectElement = useCallback((id: string | null) => {
412 flushLiveElementPatch(); 471 flushLiveElementPatch();
@@ -701,6 +760,10 @@ export function LabelTemplateEditor({ @@ -701,6 +760,10 @@ export function LabelTemplateEditor({
701 </Button> 760 </Button>
702 </div> 761 </div>
703 762
  763 + {instructionOpen ? (
  764 + <ElementsPanelInstructionPage onClose={() => setInstructionOpen(false)} />
  765 + ) : (
  766 + <>
704 {/* 模板配置区:统一 input-group 样式 */} 767 {/* 模板配置区:统一 input-group 样式 */}
705 <div className="shrink-0 border-b border-[#cfd9ea] bg-[#dde7f5] px-4 py-2"> 768 <div className="shrink-0 border-b border-[#cfd9ea] bg-[#dde7f5] px-4 py-2">
706 <div className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-2"> 769 <div className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-2">
@@ -915,7 +978,10 @@ export function LabelTemplateEditor({ @@ -915,7 +978,10 @@ export function LabelTemplateEditor({
915 boxSizing: "border-box", 978 boxSizing: "border-box",
916 }} 979 }}
917 > 980 >
918 - <ElementsPanel onAddElement={addElement} /> 981 + <ElementsPanel
  982 + onAddElement={addElement}
  983 + onOpenInstruction={() => setInstructionOpen(true)}
  984 + />
919 </div> 985 </div>
920 <div 986 <div
921 className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1" 987 className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1"
@@ -961,32 +1027,20 @@ export function LabelTemplateEditor({ @@ -961,32 +1027,20 @@ export function LabelTemplateEditor({
961 flexDirection: "column", 1027 flexDirection: "column",
962 }} 1028 }}
963 > 1029 >
964 - <div className="shrink-0 border-b border-[#c2d1e8] bg-white/80 px-2 py-2">  
965 - <p className="mb-2 text-xs font-medium text-gray-700">Print preview</p>  
966 - <div className="flex justify-center overflow-hidden">  
967 - <LabelPreviewOnly  
968 - template={previewTemplate}  
969 - maxWidth={248}  
970 - highlightElementId={selectedId}  
971 - previewRulerUnit={previewRulerUnit}  
972 - printOrientation={printOrientation}  
973 - />  
974 - </div>  
975 - </div>  
976 - <div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden">  
977 - <PropertiesPanel  
978 - template={template}  
979 - selectedElement={selectedElement}  
980 - onTemplateChange={handleTemplateChange}  
981 - onElementChange={updateElement}  
982 - onDeleteElement={deleteElement}  
983 - readOnlyTemplateCode={!!templateId}  
984 - previewRulerUnit={previewRulerUnit}  
985 - printOrientation={printOrientation}  
986 - />  
987 - </div> 1030 + <PropertiesPanel
  1031 + template={template}
  1032 + selectedElement={selectedElement}
  1033 + onTemplateChange={handleTemplateChange}
  1034 + onElementChange={updateElement}
  1035 + onDeleteElement={deleteElement}
  1036 + readOnlyTemplateCode={!!templateId}
  1037 + previewRulerUnit={previewRulerUnit}
  1038 + printOrientation={printOrientation}
  1039 + />
988 </div> 1040 </div>
989 </div> 1041 </div>
  1042 + </>
  1043 + )}
990 <Dialog open={previewOpen} onOpenChange={setPreviewOpen}> 1044 <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
991 <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 overflow-hidden flex flex-col"> 1045 <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 overflow-hidden flex flex-col">
992 <DialogHeader className="shrink-0 px-6 py-4 border-b bg-white"> 1046 <DialogHeader className="shrink-0 px-6 py-4 border-b bg-white">
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplatesView.tsx
@@ -45,7 +45,13 @@ import { @@ -45,7 +45,13 @@ import {
45 filterGroupsByCompany, 45 filterGroupsByCompany,
46 filterLocationsByCompanyAndRegion, 46 filterLocationsByCompanyAndRegion,
47 } from '../../lib/labelingToolbarScope'; 47 } from '../../lib/labelingToolbarScope';
48 -import { appliedLocationToEditor, type LabelTemplateDto, type LabelTemplateGetListInput } from '../../types/labelTemplate'; 48 +import {
  49 + appliedLocationToEditor,
  50 + elementEditorDisplayName,
  51 + sortTemplateElementsForDisplay,
  52 + type LabelTemplateDto,
  53 + type LabelTemplateGetListInput,
  54 +} from '../../types/labelTemplate';
49 import type { GroupListItem } from '../../types/group'; 55 import type { GroupListItem } from '../../types/group';
50 import type { PartnerListItem } from '../../types/partner'; 56 import type { PartnerListItem } from '../../types/partner';
51 import { LabelTemplateEditor } from './LabelTemplateEditor'; 57 import { LabelTemplateEditor } from './LabelTemplateEditor';
@@ -82,8 +88,13 @@ function templateListDisplayLocation(t: LabelTemplateDto, locations: LocationDto @@ -82,8 +88,13 @@ function templateListDisplayLocation(t: LabelTemplateDto, locations: LocationDto
82 return locationColumnText(t, locations); 88 return locationColumnText(t, locations);
83 } 89 }
84 90
85 -/** 列表行:Contents ← 接口 items / contentItems(元素名称列表) */ 91 +/** 列表行:Contents ← 接口 items / contentItems(元素展示名) */
86 function templateListContentItems(t: LabelTemplateDto): string[] { 92 function templateListContentItems(t: LabelTemplateDto): string[] {
  93 + if (t.elements?.length) {
  94 + return sortTemplateElementsForDisplay(t.elements as LabelElement[])
  95 + .map((el) => elementEditorDisplayName(el, t.elements as LabelElement[]))
  96 + .filter(Boolean);
  97 + }
87 if (Array.isArray(t.contentItems) && t.contentItems.length > 0) { 98 if (Array.isArray(t.contentItems) && t.contentItems.length > 0) {
88 return t.contentItems.map((x) => String(x).trim()).filter(Boolean); 99 return t.contentItems.map((x) => String(x).trim()).filter(Boolean);
89 } 100 }
@@ -93,11 +104,6 @@ function templateListContentItems(t: LabelTemplateDto): string[] { @@ -93,11 +104,6 @@ function templateListContentItems(t: LabelTemplateDto): string[] {
93 if (typeof t.items === "string" && t.items.trim()) { 104 if (typeof t.items === "string" && t.items.trim()) {
94 return [t.items.trim()]; 105 return [t.items.trim()];
95 } 106 }
96 - if (t.elements?.length) {  
97 - return (t.elements ?? [])  
98 - .map((el) => String(el.elementName ?? "").trim())  
99 - .filter(Boolean);  
100 - }  
101 return []; 107 return [];
102 } 108 }
103 109
美国版/Food Labeling Management Platform/src/components/labels/LabelsList.tsx
@@ -67,6 +67,7 @@ import type { ProductCategoryDto } from &quot;../../types/productCategory&quot;; @@ -67,6 +67,7 @@ import type { ProductCategoryDto } from &quot;../../types/productCategory&quot;;
67 import type { ProductLocationLinkDto } from "../../types/productLocation"; 67 import type { ProductLocationLinkDto } from "../../types/productLocation";
68 import { getProductLocations } from "../../services/productLocationService"; 68 import { getProductLocations } from "../../services/productLocationService";
69 import { LabelTemplateDataEntryView } from "./LabelTemplateDataEntryView"; 69 import { LabelTemplateDataEntryView } from "./LabelTemplateDataEntryView";
  70 +import { NutritionManualEntryForm } from "./NutritionManualEntryForm";
70 import { LabelPreviewOnly } from "./LabelTemplateEditor/LabelCanvas"; 71 import { LabelPreviewOnly } from "./LabelTemplateEditor/LabelCanvas";
71 import { 72 import {
72 appliedLocationToEditor, 73 appliedLocationToEditor,
@@ -2517,7 +2518,7 @@ function CreateLabelDialog({ @@ -2517,7 +2518,7 @@ function CreateLabelDialog({
2517 const templateScanLocked = isTemplateSectionBarcodeOrQrElement(el); 2518 const templateScanLocked = isTemplateSectionBarcodeOrQrElement(el);
2518 return ( 2519 return (
2519 <div key={el.id} className="space-y-1.5 w-full min-w-0"> 2520 <div key={el.id} className="space-y-1.5 w-full min-w-0">
2520 - <Label className="block">{dataEntryColumnLabel(el)}</Label> 2521 + <Label className="block">{dataEntryColumnLabel(el, (selectedTemplate?.elements ?? []) as LabelElement[])}</Label>
2521 {templateScanLocked ? ( 2522 {templateScanLocked ? (
2522 <Input 2523 <Input
2523 className="h-10 w-full min-w-0 box-border bg-gray-50" 2524 className="h-10 w-full min-w-0 box-border bg-gray-50"
@@ -2575,33 +2576,31 @@ function CreateLabelDialog({ @@ -2575,33 +2576,31 @@ function CreateLabelDialog({
2575 onChange={(e) => 2576 onChange={(e) =>
2576 setTemplateDataValues((prev) => ({ ...prev, [el.id]: e.target.value })) 2577 setTemplateDataValues((prev) => ({ ...prev, [el.id]: e.target.value }))
2577 } 2578 }
2578 - placeholder={`Enter ${dataEntryColumnLabel(el)}`} 2579 + placeholder={`Enter ${dataEntryColumnLabel(el, (selectedTemplate?.elements ?? []) as LabelElement[])}`}
2579 /> 2580 />
2580 )} 2581 )}
2581 </div> 2582 </div>
2582 ); 2583 );
2583 })} 2584 })}
2584 {nutritionFieldBlocks.length > 0 ? ( 2585 {nutritionFieldBlocks.length > 0 ? (
2585 - <div className="pt-2 mt-2 border-t border-gray-200 space-y-3">  
2586 - <div className="text-xs font-semibold text-gray-700">Nutrition Facts (manual)</div>  
2587 - {nutritionFieldBlocks.map(({ el: nel, spec }) => (  
2588 - <div key={`${nel.id}-${spec.subKey}`} className="space-y-1.5 w-full min-w-0">  
2589 - <Label className="block">{spec.columnLabel}</Label>  
2590 - <Input  
2591 - className="h-10 w-full min-w-0 box-border"  
2592 - value={nutritionByElementId[nel.id]?.[spec.subKey] ?? ""}  
2593 - onChange={(e) =>  
2594 - setNutritionByElementId((prev) => ({  
2595 - ...prev,  
2596 - [nel.id]: {  
2597 - ...(prev[nel.id] ?? {}),  
2598 - [spec.subKey]: e.target.value,  
2599 - },  
2600 - }))  
2601 - }  
2602 - placeholder={`Enter ${spec.columnLabel}`}  
2603 - />  
2604 - </div> 2586 + <div className="pt-2 mt-2 border-t border-gray-200">
  2587 + <div className="text-xs font-semibold text-gray-700 mb-3">Nutrition Facts</div>
  2588 + {listNutritionElements((selectedTemplate.elements ?? []) as LabelElement[]).map((nel) => (
  2589 + <NutritionManualEntryForm
  2590 + key={nel.id}
  2591 + element={nel}
  2592 + values={nutritionByElementId[nel.id] ?? {}}
  2593 + onFieldChange={(subKey, next) =>
  2594 + setNutritionByElementId((prev) => ({
  2595 + ...prev,
  2596 + [nel.id]: {
  2597 + ...(prev[nel.id] ?? {}),
  2598 + [subKey]: next,
  2599 + },
  2600 + }))
  2601 + }
  2602 + className="space-y-4"
  2603 + />
2605 ))} 2604 ))}
2606 </div> 2605 </div>
2607 ) : null} 2606 ) : null}
@@ -3503,7 +3502,7 @@ function EditLabelDialog({ @@ -3503,7 +3502,7 @@ function EditLabelDialog({
3503 const templateScanLocked = isTemplateSectionBarcodeOrQrElement(el); 3502 const templateScanLocked = isTemplateSectionBarcodeOrQrElement(el);
3504 return ( 3503 return (
3505 <div key={el.id} className="space-y-1.5 w-full min-w-0"> 3504 <div key={el.id} className="space-y-1.5 w-full min-w-0">
3506 - <Label className="block">{dataEntryColumnLabel(el)}</Label> 3505 + <Label className="block">{dataEntryColumnLabel(el, (selectedTemplate?.elements ?? []) as LabelElement[])}</Label>
3507 {templateScanLocked ? ( 3506 {templateScanLocked ? (
3508 <Input 3507 <Input
3509 className="h-10 w-full min-w-0 box-border bg-gray-50" 3508 className="h-10 w-full min-w-0 box-border bg-gray-50"
@@ -3561,33 +3560,31 @@ function EditLabelDialog({ @@ -3561,33 +3560,31 @@ function EditLabelDialog({
3561 onChange={(e) => 3560 onChange={(e) =>
3562 setTemplateDataValues((prev) => ({ ...prev, [el.id]: e.target.value })) 3561 setTemplateDataValues((prev) => ({ ...prev, [el.id]: e.target.value }))
3563 } 3562 }
3564 - placeholder={`Enter ${dataEntryColumnLabel(el)}`} 3563 + placeholder={`Enter ${dataEntryColumnLabel(el, (selectedTemplate?.elements ?? []) as LabelElement[])}`}
3565 /> 3564 />
3566 )} 3565 )}
3567 </div> 3566 </div>
3568 ); 3567 );
3569 })} 3568 })}
3570 {editNutritionFieldBlocks.length > 0 ? ( 3569 {editNutritionFieldBlocks.length > 0 ? (
3571 - <div className="pt-2 mt-2 border-t border-gray-200 space-y-3">  
3572 - <div className="text-xs font-semibold text-gray-700">Nutrition Facts (manual)</div>  
3573 - {editNutritionFieldBlocks.map(({ el: nel, spec }) => (  
3574 - <div key={`${nel.id}-${spec.subKey}`} className="space-y-1.5 w-full min-w-0">  
3575 - <Label className="block">{spec.columnLabel}</Label>  
3576 - <Input  
3577 - className="h-10 w-full min-w-0 box-border"  
3578 - value={nutritionByElementId[nel.id]?.[spec.subKey] ?? ""}  
3579 - onChange={(e) =>  
3580 - setNutritionByElementId((prev) => ({  
3581 - ...prev,  
3582 - [nel.id]: {  
3583 - ...(prev[nel.id] ?? {}),  
3584 - [spec.subKey]: e.target.value,  
3585 - },  
3586 - }))  
3587 - }  
3588 - placeholder={`Enter ${spec.columnLabel}`}  
3589 - />  
3590 - </div> 3570 + <div className="pt-2 mt-2 border-t border-gray-200">
  3571 + <div className="text-xs font-semibold text-gray-700 mb-3">Nutrition Facts</div>
  3572 + {listNutritionElements((selectedTemplate.elements ?? []) as LabelElement[]).map((nel) => (
  3573 + <NutritionManualEntryForm
  3574 + key={nel.id}
  3575 + element={nel}
  3576 + values={nutritionByElementId[nel.id] ?? {}}
  3577 + onFieldChange={(subKey, next) =>
  3578 + setNutritionByElementId((prev) => ({
  3579 + ...prev,
  3580 + [nel.id]: {
  3581 + ...(prev[nel.id] ?? {}),
  3582 + [subKey]: next,
  3583 + },
  3584 + }))
  3585 + }
  3586 + className="space-y-4"
  3587 + />
3591 ))} 3588 ))}
3592 </div> 3589 </div>
3593 ) : null} 3590 ) : null}
美国版/Food Labeling Management Platform/src/components/labels/NutritionManualEntryForm.tsx 0 → 100644
  1 +import React from "react";
  2 +import { Input } from "../ui/input";
  3 +import { Label } from "../ui/label";
  4 +import type { LabelElement } from "../../types/labelTemplate";
  5 +import { NUTRITION_FACTS_LAYOUT_ROWS } from "../../lib/nutritionFactsLayout";
  6 +import { listNutritionManualFieldSpecs } from "../../lib/nutritionManualEntry";
  7 +
  8 +function pick(values: Record<string, string>, key: string): string {
  9 + return String(values[key] ?? "");
  10 +}
  11 +
  12 +type NutrientRowDef = {
  13 + key: string;
  14 + label: string;
  15 + indent?: boolean;
  16 +};
  17 +
  18 +function nutrientRowsForElement(el: LabelElement): NutrientRowDef[] {
  19 + const cfg = (el.config ?? {}) as Record<string, unknown>;
  20 + const fixed = Array.isArray(cfg.fixedNutrients)
  21 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  22 + : [];
  23 + const layoutByKey = new Map(NUTRITION_FACTS_LAYOUT_ROWS.map((r) => [r.key, r]));
  24 + const rows: NutrientRowDef[] = NUTRITION_FACTS_LAYOUT_ROWS.map((r) => ({
  25 + key: r.key,
  26 + label: r.label,
  27 + indent: r.indent,
  28 + }));
  29 + for (const row of fixed) {
  30 + const key = String(row.key ?? "").trim();
  31 + if (!key || layoutByKey.has(key)) {
  32 + if (key && layoutByKey.has(key) && row.label) {
  33 + const idx = rows.findIndex((x) => x.key === key);
  34 + if (idx >= 0) rows[idx] = { ...rows[idx], label: String(row.label) };
  35 + }
  36 + continue;
  37 + }
  38 + rows.push({
  39 + key,
  40 + label: String(row.label ?? key),
  41 + indent: false,
  42 + });
  43 + }
  44 + return rows;
  45 +}
  46 +
  47 +function extraNutrientIds(el: LabelElement): Array<{ id: string; name: string }> {
  48 + const specs = listNutritionManualFieldSpecs(el);
  49 + const out: Array<{ id: string; name: string }> = [];
  50 + for (const s of specs) {
  51 + if (!s.subKey.startsWith("extra:") || !s.subKey.endsWith(":value")) continue;
  52 + const id = s.subKey.slice("extra:".length, -":value".length);
  53 + if (out.some((x) => x.id === id)) continue;
  54 + out.push({ id, name: s.columnLabel.replace(/ \(amount\)$/, "") });
  55 + }
  56 + return out;
  57 +}
  58 +
  59 +/** 分组录入:顶部 Servings / Serve size / Calories + 每行「含量 + %DV」(< 前缀在模板编辑器配置) */
  60 +export function NutritionManualEntryForm({
  61 + element,
  62 + values,
  63 + onFieldChange,
  64 + className,
  65 +}: {
  66 + element: LabelElement;
  67 + values: Record<string, string>;
  68 + onFieldChange: (subKey: string, next: string) => void;
  69 + className?: string;
  70 +}) {
  71 + const nutrientRows = nutrientRowsForElement(element);
  72 + const extras = extraNutrientIds(element);
  73 +
  74 + return (
  75 + <div className={className ?? "space-y-4"}>
  76 + <div className="space-y-2">
  77 + <div className="text-xs font-semibold text-gray-700">Top metrics</div>
  78 + <div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
  79 + <FieldPair
  80 + label="Servings"
  81 + value={pick(values, "servingsPerContainer")}
  82 + onChange={(v) => onFieldChange("servingsPerContainer", v)}
  83 + placeholder="e.g. 6"
  84 + />
  85 + <FieldPair
  86 + label="Serve size"
  87 + value={pick(values, "servingSize")}
  88 + onChange={(v) => onFieldChange("servingSize", v)}
  89 + placeholder="e.g. 500g"
  90 + />
  91 + </div>
  92 + <FieldPair
  93 + label="Calories"
  94 + value={pick(values, "calories")}
  95 + onChange={(v) => onFieldChange("calories", v)}
  96 + placeholder="e.g. 368"
  97 + />
  98 + </div>
  99 +
  100 + <div className="space-y-2">
  101 + <div className="text-xs font-semibold text-gray-700">Nutrients</div>
  102 + {nutrientRows.map((row) => (
  103 + <NutrientInputRow
  104 + key={row.key}
  105 + label={row.label}
  106 + indent={row.indent}
  107 + amount={pick(values, row.key)}
  108 + percent={pick(values, `${row.key}Percent`)}
  109 + onAmount={(v) => onFieldChange(row.key, v)}
  110 + onPercent={(v) => onFieldChange(`${row.key}Percent`, v)}
  111 + />
  112 + ))}
  113 + {extras.map((ex) => (
  114 + <NutrientInputRow
  115 + key={ex.id}
  116 + label={ex.name}
  117 + amount={pick(values, `extra:${ex.id}:value`)}
  118 + percent={pick(values, `extra:${ex.id}:percent`)}
  119 + onAmount={(v) => onFieldChange(`extra:${ex.id}:value`, v)}
  120 + onPercent={(v) => onFieldChange(`extra:${ex.id}:percent`, v)}
  121 + />
  122 + ))}
  123 + </div>
  124 + </div>
  125 + );
  126 +}
  127 +
  128 +/** 从 element config 读取当前预览/录入值(模板编辑器画布同步用) */
  129 +export function nutritionValuesFromElementConfig(cfg: Record<string, unknown>): Record<string, string> {
  130 + const out: Record<string, string> = {
  131 + servingsPerContainer: String(cfg.servingsPerContainer ?? cfg.ServingsPerContainer ?? ""),
  132 + servingSize: String(cfg.servingSize ?? cfg.ServingSize ?? ""),
  133 + calories: String(cfg.calories ?? cfg.Calories ?? ""),
  134 + };
  135 + const fixed = Array.isArray(cfg.fixedNutrients)
  136 + ? (cfg.fixedNutrients as Record<string, unknown>[])
  137 + : [];
  138 + for (const row of fixed) {
  139 + const key = String(row.key ?? "").trim();
  140 + if (!key) continue;
  141 + out[key] = String(row.value ?? cfg[key] ?? "");
  142 + out[`${key}Percent`] = String(row.dailyValuePercent ?? row.percent ?? cfg[`${key}Percent`] ?? "");
  143 + }
  144 + for (const item of NUTRITION_FACTS_LAYOUT_ROWS) {
  145 + if (out[item.key] === undefined) out[item.key] = String(cfg[item.key] ?? "");
  146 + if (out[`${item.key}Percent`] === undefined) {
  147 + out[`${item.key}Percent`] = String(cfg[`${item.key}Percent`] ?? "");
  148 + }
  149 + }
  150 + const extras = Array.isArray(cfg.extraNutrients) ? cfg.extraNutrients : [];
  151 + extras.forEach((item, idx) => {
  152 + const row = item as Record<string, unknown>;
  153 + const id = String(row.id ?? `extra-${idx}`);
  154 + out[`extra:${id}:value`] = String(row.value ?? "");
  155 + out[`extra:${id}:percent`] = String(cfg[`extra:${id}:percent`] ?? "");
  156 + });
  157 + return out;
  158 +}
  159 +
  160 +/** 将分组录入写回 element config(模板编辑器预览;保存模板时仍会清空数值) */
  161 +export function applyNutritionValuesToElementConfig(
  162 + baseCfg: Record<string, unknown>,
  163 + values: Record<string, string>,
  164 +): Record<string, unknown> {
  165 + const cfg: Record<string, unknown> = { ...baseCfg };
  166 + cfg.servingsPerContainer = pick(values, "servingsPerContainer");
  167 + cfg.servingSize = pick(values, "servingSize");
  168 + cfg.calories = pick(values, "calories");
  169 +
  170 + const baseFixed = Array.isArray(baseCfg.fixedNutrients)
  171 + ? (baseCfg.fixedNutrients as Record<string, unknown>[])
  172 + : [];
  173 + const keys =
  174 + baseFixed.length > 0
  175 + ? baseFixed.map((r) => String(r.key ?? "").trim()).filter(Boolean)
  176 + : NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key);
  177 +
  178 + const fixedArr = keys.map((key) => {
  179 + const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === key);
  180 + const layout = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key);
  181 + const unit = String(baseRow?.unit ?? layout?.defaultUnit ?? "");
  182 + const label = String(baseRow?.label ?? layout?.label ?? key);
  183 + const value = pick(values, key);
  184 + const dailyValuePercent = pick(values, `${key}Percent`);
  185 + const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`]);
  186 + if (value) cfg[key] = value;
  187 + else delete cfg[key];
  188 + delete cfg[`${key}Unit`];
  189 + cfg[`${key}Percent`] = dailyValuePercent;
  190 + cfg[`${key}LessThan`] = lessThan;
  191 + return { key, label, value, unit, dailyValuePercent, lessThan };
  192 + });
  193 + cfg.fixedNutrients = fixedArr;
  194 +
  195 + const extras = Array.isArray(baseCfg.extraNutrients) ? [...(baseCfg.extraNutrients as object[])] : [];
  196 + for (const ex of extras) {
  197 + const row = ex as Record<string, unknown>;
  198 + const id = String(row.id ?? "");
  199 + if (!id) continue;
  200 + row.value = pick(values, `extra:${id}:value`);
  201 + cfg[`extra:${id}:percent`] = pick(values, `extra:${id}:percent`);
  202 + cfg[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`]);
  203 + }
  204 + cfg.extraNutrients = extras;
  205 + return cfg;
  206 +}
  207 +
  208 +function FieldPair({
  209 + label,
  210 + value,
  211 + onChange,
  212 + placeholder,
  213 +}: {
  214 + label: string;
  215 + value: string;
  216 + onChange: (v: string) => void;
  217 + placeholder?: string;
  218 +}) {
  219 + return (
  220 + <div className="space-y-1">
  221 + <Label className="text-xs text-gray-600">{label}</Label>
  222 + <Input
  223 + value={value}
  224 + onChange={(e) => onChange(e.target.value)}
  225 + placeholder={placeholder}
  226 + className="h-8 text-sm"
  227 + />
  228 + </div>
  229 + );
  230 +}
  231 +
  232 +function NutrientInputRow({
  233 + label,
  234 + indent,
  235 + amount,
  236 + percent,
  237 + onAmount,
  238 + onPercent,
  239 +}: {
  240 + label: string;
  241 + indent?: boolean;
  242 + amount: string;
  243 + percent: string;
  244 + onAmount: (v: string) => void;
  245 + onPercent: (v: string) => void;
  246 +}) {
  247 + return (
  248 + <div className="grid grid-cols-[1fr_88px_72px] gap-1.5 items-center">
  249 + <span className={`text-xs text-gray-700 truncate ${indent ? "pl-3" : ""}`}>{label}</span>
  250 + <Input
  251 + value={amount}
  252 + onChange={(e) => onAmount(e.target.value)}
  253 + placeholder="e.g. 11g"
  254 + className="h-8 text-sm text-right"
  255 + />
  256 + <Input
  257 + value={percent}
  258 + onChange={(e) => onPercent(e.target.value)}
  259 + placeholder="0%"
  260 + className="h-8 text-sm text-right"
  261 + />
  262 + </div>
  263 + );
  264 +}
  265 +
  266 +export type { NutritionManualFieldSpec } from "../../lib/nutritionManualEntry";
美国版/Food Labeling Management Platform/src/components/labels/NutritionManualFieldControl.tsx 0 → 100644
  1 +import React from "react";
  2 +import { Input } from "../ui/input";
  3 +import { Label } from "../ui/label";
  4 +import { Checkbox } from "../ui/checkbox";
  5 +import type { NutritionManualFieldSpec } from "../../lib/nutritionManualEntry";
  6 +
  7 +function isTruthyNutritionFlag(raw: string): boolean {
  8 + const v = String(raw ?? "").trim().toLowerCase();
  9 + return v === "true" || v === "1" || v === "yes" || v === "on";
  10 +}
  11 +
  12 +export function NutritionManualFieldControl({
  13 + spec,
  14 + value,
  15 + onChange,
  16 + inputClassName,
  17 + showLabel = true,
  18 +}: {
  19 + spec: NutritionManualFieldSpec;
  20 + value: string;
  21 + onChange: (next: string) => void;
  22 + inputClassName?: string;
  23 + showLabel?: boolean;
  24 +}) {
  25 + if (spec.inputType === "checkbox") {
  26 + return (
  27 + <div className="flex items-center gap-2 min-h-10">
  28 + <Checkbox
  29 + id={`nut-${spec.subKey}`}
  30 + checked={isTruthyNutritionFlag(value)}
  31 + onCheckedChange={(checked) => onChange(checked ? "true" : "false")}
  32 + />
  33 + {showLabel ? (
  34 + <Label htmlFor={`nut-${spec.subKey}`} className="text-sm font-normal cursor-pointer">
  35 + {spec.columnLabel}
  36 + </Label>
  37 + ) : null}
  38 + </div>
  39 + );
  40 + }
  41 +
  42 + return (
  43 + <div className="space-y-1.5 w-full min-w-0">
  44 + {showLabel ? <Label className="block">{spec.columnLabel}</Label> : null}
  45 + <Input
  46 + className={inputClassName ?? "h-10 w-full min-w-0 box-border"}
  47 + value={value}
  48 + onChange={(e) => onChange(e.target.value)}
  49 + placeholder={`Enter ${spec.columnLabel}`}
  50 + />
  51 + </div>
  52 + );
  53 +}
美国版/Food Labeling Management Platform/src/components/products/ProductsView.tsx
@@ -989,7 +989,7 @@ export function ProductsView() { @@ -989,7 +989,7 @@ export function ProductsView() {
989 <TableCell className="border-r text-sm font-normal text-gray-900"> 989 <TableCell className="border-r text-sm font-normal text-gray-900">
990 <div className="flex items-center gap-2 min-w-0"> 990 <div className="flex items-center gap-2 min-w-0">
991 {productVisual.mode !== "none" ? ( 991 {productVisual.mode !== "none" ? (
992 - <CategoryButtonVisualThumb visual={productVisual} size="sm" /> 992 + <CategoryButtonVisualThumb visual={productVisual} variant="productCategory" />
993 ) : ( 993 ) : (
994 <Package className="w-4 h-4 text-gray-400 shrink-0" /> 994 <Package className="w-4 h-4 text-gray-400 shrink-0" />
995 )} 995 )}
@@ -1179,7 +1179,7 @@ export function ProductsView() { @@ -1179,7 +1179,7 @@ export function ProductsView() {
1179 {visual.mode === "none" ? ( 1179 {visual.mode === "none" ? (
1180 <span className="text-sm font-normal text-gray-900">—</span> 1180 <span className="text-sm font-normal text-gray-900">—</span>
1181 ) : ( 1181 ) : (
1182 - <CategoryButtonVisualThumb visual={visual} size="md" /> 1182 + <CategoryButtonVisualThumb visual={visual} variant="productCategory" />
1183 )} 1183 )}
1184 </TableCell> 1184 </TableCell>
1185 <TableCell 1185 <TableCell
@@ -2550,6 +2550,7 @@ function ProductCategoryFormDialog({ @@ -2550,6 +2550,7 @@ function ProductCategoryFormDialog({
2550 ) : null} 2550 ) : null}
2551 2551
2552 <CategoryButtonAppearancePreview 2552 <CategoryButtonAppearancePreview
  2553 + variant="productCategory"
2553 apSel={apSel} 2554 apSel={apSel}
2554 displayText={displayText} 2555 displayText={displayText}
2555 buttonBgColor={buttonBgColor} 2556 buttonBgColor={buttonBgColor}
美国版/Food Labeling Management Platform/src/components/ui/category-button-appearance-preview.tsx
1 import { useMemo } from "react"; 1 import { useMemo } from "react";
2 -import { buildCategoryVisualFromAppearanceForm } from "../../lib/categoryButtonAppearance"; 2 +import {
  3 + APP_LABEL_CATEGORY_THUMB_RPX,
  4 + APP_PRODUCT_CATEGORY_THUMB_RPX,
  5 + buildCategoryVisualFromAppearanceForm,
  6 + type AppCategoryThumbVariant,
  7 +} from "../../lib/categoryButtonAppearance";
3 import { CategoryButtonVisualThumb } from "./category-button-visual-thumb"; 8 import { CategoryButtonVisualThumb } from "./category-button-visual-thumb";
4 import { Label } from "./label"; 9 import { Label } from "./label";
5 10
6 type AppearanceSelection = { text: boolean; color: boolean; image: boolean }; 11 type AppearanceSelection = { text: boolean; color: boolean; image: boolean };
7 12
8 -const SIZE_LABELS = [  
9 - { size: "sm" as const, label: "32×32" },  
10 - { size: "md" as const, label: "48×48" },  
11 - { size: "preview" as const, label: "App card" },  
12 -]; 13 +const VARIANT_HINT: Record<AppCategoryThumbVariant, string> = {
  14 + labelCategory: `Fixed ${APP_LABEL_CATEGORY_THUMB_RPX}rpx square — same as App label category sidebar.`,
  15 + productCategory: `Fixed ${APP_PRODUCT_CATEGORY_THUMB_RPX}rpx square — same as App product category header.`,
  16 +};
13 17
14 export function CategoryButtonAppearancePreview({ 18 export function CategoryButtonAppearancePreview({
  19 + variant = "labelCategory",
15 apSel, 20 apSel,
16 displayText, 21 displayText,
17 buttonBgColor, 22 buttonBgColor,
18 imageUrl, 23 imageUrl,
19 fallbackName, 24 fallbackName,
20 }: { 25 }: {
  26 + /** 与 App 展示场景对齐:Label Category 侧栏 / Product Category 标题 */
  27 + variant?: AppCategoryThumbVariant;
21 apSel: AppearanceSelection; 28 apSel: AppearanceSelection;
22 displayText: string; 29 displayText: string;
23 buttonBgColor: string; 30 buttonBgColor: string;
@@ -39,13 +46,9 @@ export function CategoryButtonAppearancePreview({ @@ -39,13 +46,9 @@ export function CategoryButtonAppearancePreview({
39 return ( 46 return (
40 <div className="space-y-2"> 47 <div className="space-y-2">
41 <Label>Display preview</Label> 48 <Label>Display preview</Label>
42 - <div className="flex flex-wrap items-end gap-4 rounded-2xl border border-gray-200 bg-gray-50 p-3">  
43 - {SIZE_LABELS.map(({ size, label }) => (  
44 - <div key={size} className="flex flex-col items-center gap-1.5">  
45 - <CategoryButtonVisualThumb visual={visual} size={size} />  
46 - <span className="text-[10px] text-gray-500">{label}</span>  
47 - </div>  
48 - ))} 49 + <p className="text-xs text-gray-500">{VARIANT_HINT[variant]}</p>
  50 + <div className="inline-flex rounded-2xl border border-gray-200 bg-gray-50 p-3">
  51 + <CategoryButtonVisualThumb visual={visual} variant={variant} />
49 </div> 52 </div>
50 </div> 53 </div>
51 ); 54 );
美国版/Food Labeling Management Platform/src/components/ui/category-button-visual-thumb.tsx
1 import type { CSSProperties } from "react"; 1 import type { CSSProperties } from "react";
2 -import type { CategoryVisualRender } from "../../lib/categoryButtonAppearance"; 2 +import type {
  3 + AppCategoryThumbVariant,
  4 + CategoryVisualRender,
  5 +} from "../../lib/categoryButtonAppearance";
  6 +import {
  7 + APP_LABEL_CATEGORY_THUMB_CSS_PX,
  8 + APP_PRODUCT_CATEGORY_THUMB_CSS_PX,
  9 +} from "../../lib/categoryButtonAppearance";
3 import { resolvePictureUrlForDisplay } from "../../services/imageUploadService"; 10 import { resolvePictureUrlForDisplay } from "../../services/imageUploadService";
4 import { cn } from "./utils"; 11 import { cn } from "./utils";
5 12
6 -type Size = "sm" | "md" | "preview";  
7 -  
8 -const THUMB_SIZE: Record<Size, { width: number; height: number }> = {  
9 - sm: { width: 32, height: 32 },  
10 - md: { width: 48, height: 48 },  
11 - preview: { width: 140, height: 81 }, 13 +const THUMB_SIZE: Record<AppCategoryThumbVariant, { width: number; height: number; radius: number; fontSize: number; pad: number }> = {
  14 + labelCategory: {
  15 + width: APP_LABEL_CATEGORY_THUMB_CSS_PX,
  16 + height: APP_LABEL_CATEGORY_THUMB_CSS_PX,
  17 + radius: 7,
  18 + fontSize: 10,
  19 + pad: 2,
  20 + },
  21 + productCategory: {
  22 + width: APP_PRODUCT_CATEGORY_THUMB_CSS_PX,
  23 + height: APP_PRODUCT_CATEGORY_THUMB_CSS_PX,
  24 + radius: 6,
  25 + fontSize: 10,
  26 + pad: 2,
  27 + },
12 }; 28 };
13 29
14 const TEXT_WRAP_STYLE: CSSProperties = { 30 const TEXT_WRAP_STYLE: CSSProperties = {
@@ -23,8 +39,8 @@ const TEXT_WRAP_STYLE: CSSProperties = { @@ -23,8 +39,8 @@ const TEXT_WRAP_STYLE: CSSProperties = {
23 lineHeight: 1.15, 39 lineHeight: 1.15,
24 }; 40 };
25 41
26 -function thumbBoxStyle(size: Size, extra?: CSSProperties): CSSProperties {  
27 - const { width, height } = THUMB_SIZE[size]; 42 +function thumbBoxStyle(variant: AppCategoryThumbVariant, extra?: CSSProperties): CSSProperties {
  43 + const { width, height } = THUMB_SIZE[variant];
28 return { 44 return {
29 width, 45 width,
30 height, 46 height,
@@ -37,35 +53,36 @@ function thumbBoxStyle(size: Size, extra?: CSSProperties): CSSProperties { @@ -37,35 +53,36 @@ function thumbBoxStyle(size: Size, extra?: CSSProperties): CSSProperties {
37 }; 53 };
38 } 54 }
39 55
40 -function textThumbShellStyle(size: Size, extra?: CSSProperties): CSSProperties {  
41 - return thumbBoxStyle(size, { 56 +function textThumbShellStyle(variant: AppCategoryThumbVariant, extra?: CSSProperties): CSSProperties {
  57 + const { radius, pad } = THUMB_SIZE[variant];
  58 + return thumbBoxStyle(variant, {
42 display: "flex", 59 display: "flex",
43 alignItems: "center", 60 alignItems: "center",
44 justifyContent: "center", 61 justifyContent: "center",
45 overflow: "hidden", 62 overflow: "hidden",
46 - padding: size === "preview" ? 4 : 2, 63 + padding: pad,
  64 + borderRadius: radius,
47 ...extra, 65 ...extra,
48 }); 66 });
49 } 67 }
50 68
51 -/** 产品分类 / 产品列表:按 buttonAppearance + categoryPhotoUrl 渲染色块或图片 */ 69 +/** 产品分类 / 产品列表 / 表单预览:与 App 固定方框外框一致 */
52 export function CategoryButtonVisualThumb({ 70 export function CategoryButtonVisualThumb({
53 visual, 71 visual,
54 - size = "md", 72 + variant = "labelCategory",
55 className, 73 className,
56 }: { 74 }: {
57 visual: CategoryVisualRender; 75 visual: CategoryVisualRender;
58 - size?: Size; 76 + variant?: AppCategoryThumbVariant;
59 className?: string; 77 className?: string;
60 }) { 78 }) {
61 - const radius = size === "sm" ? 8 : size === "md" ? 12 : 14;  
62 - const fontSize = size === "sm" ? 10 : size === "md" ? 11 : 13; 79 + const { radius, fontSize } = THUMB_SIZE[variant];
63 80
64 if (visual.mode === "image") { 81 if (visual.mode === "image") {
65 return ( 82 return (
66 <div 83 <div
67 className={cn("border border-gray-200 overflow-hidden shadow-sm bg-gray-50 shrink-0", className)} 84 className={cn("border border-gray-200 overflow-hidden shadow-sm bg-gray-50 shrink-0", className)}
68 - style={thumbBoxStyle(size, { borderRadius: radius })} 85 + style={thumbBoxStyle(variant, { borderRadius: radius })}
69 > 86 >
70 <img 87 <img
71 src={resolvePictureUrlForDisplay(visual.imageUrl)} 88 src={resolvePictureUrlForDisplay(visual.imageUrl)}
@@ -80,14 +97,13 @@ export function CategoryButtonVisualThumb({ @@ -80,14 +97,13 @@ export function CategoryButtonVisualThumb({
80 return ( 97 return (
81 <div 98 <div
82 className={cn("border border-gray-200 shadow-sm shrink-0", className)} 99 className={cn("border border-gray-200 shadow-sm shrink-0", className)}
83 - style={textThumbShellStyle(size, {  
84 - borderRadius: radius, 100 + style={textThumbShellStyle(variant, {
85 backgroundColor: visual.bg, 101 backgroundColor: visual.bg,
86 color: visual.textColor || "#ffffff", 102 color: visual.textColor || "#ffffff",
87 })} 103 })}
88 title={visual.text} 104 title={visual.text}
89 > 105 >
90 - <span style={{ ...TEXT_WRAP_STYLE, fontSize }}>{visual.text}</span> 106 + <span style={{ ...TEXT_WRAP_STYLE, fontSize, fontWeight: 700 }}>{visual.text}</span>
91 </div> 107 </div>
92 ); 108 );
93 } 109 }
@@ -96,7 +112,7 @@ export function CategoryButtonVisualThumb({ @@ -96,7 +112,7 @@ export function CategoryButtonVisualThumb({
96 return ( 112 return (
97 <div 113 <div
98 className={cn("border border-gray-200 shadow-sm shrink-0", className)} 114 className={cn("border border-gray-200 shadow-sm shrink-0", className)}
99 - style={thumbBoxStyle(size, { 115 + style={thumbBoxStyle(variant, {
100 borderRadius: radius, 116 borderRadius: radius,
101 backgroundColor: visual.bg, 117 backgroundColor: visual.bg,
102 })} 118 })}
@@ -109,10 +125,10 @@ export function CategoryButtonVisualThumb({ @@ -109,10 +125,10 @@ export function CategoryButtonVisualThumb({
109 return ( 125 return (
110 <div 126 <div
111 className={cn("border border-gray-200 shadow-sm bg-gray-50 shrink-0", className)} 127 className={cn("border border-gray-200 shadow-sm bg-gray-50 shrink-0", className)}
112 - style={textThumbShellStyle(size, { borderRadius: radius })} 128 + style={textThumbShellStyle(variant)}
113 title={visual.text} 129 title={visual.text}
114 > 130 >
115 - <span style={{ ...TEXT_WRAP_STYLE, fontSize, color: "#1f2937" }}>{visual.text}</span> 131 + <span style={{ ...TEXT_WRAP_STYLE, fontSize, fontWeight: 700, color: "#1f2937" }}>{visual.text}</span>
116 </div> 132 </div>
117 ); 133 );
118 } 134 }
美国版/Food Labeling Management Platform/src/lib/categoryButtonAppearance.ts
@@ -425,3 +425,14 @@ export function resolveCategoryButtonVisual(input: CategoryVisualInput): Categor @@ -425,3 +425,14 @@ export function resolveCategoryButtonVisual(input: CategoryVisualInput): Categor
425 425
426 return { mode: "none" }; 426 return { mode: "none" };
427 } 427 }
  428 +
  429 +/** App labels.vue 侧栏 Label Category:ICON_FIXED_RPX=64 → 约 32px(375 逻辑宽) */
  430 +export const APP_LABEL_CATEGORY_THUMB_CSS_PX = 32;
  431 +export const APP_LABEL_CATEGORY_THUMB_RPX = 64;
  432 +
  433 +/** App labels.vue 产品分类标题:fixedIconBoxStyle(72) → 约 36px */
  434 +export const APP_PRODUCT_CATEGORY_THUMB_CSS_PX = 36;
  435 +export const APP_PRODUCT_CATEGORY_THUMB_RPX = 72;
  436 +
  437 +/** 平台端预览 / 列表缩略图与 App 外框对齐 */
  438 +export type AppCategoryThumbVariant = "labelCategory" | "productCategory";
美国版/Food Labeling Management Platform/src/lib/nutritionFactsLayout.ts 0 → 100644
  1 +/**
  2 + * US Nutrition Facts 面板布局(与图2一致):三列(名称 / 含量 / %DV)、粗细分隔线、可选 "<" 前缀。
  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 +/** 模板固定营养成分行(图2顺序与文案;大类 dividerAfter=double,其他=thin) */
  16 +export const NUTRITION_FACTS_LAYOUT_ROWS: readonly NutritionLayoutRowDef[] = [
  17 + { key: 'fat', label: 'Total Fat', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  18 + { key: 'transFat', label: 'Trans Fat', defaultUnit: 'g', indent: true, dividerAfter: 'thin' },
  19 + { key: 'cholesterol', label: 'Cholesterol', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' },
  20 + { key: 'sodium', label: 'Sodium', defaultUnit: 'mg', labelBold: true, dividerAfter: 'double' },
  21 + { key: 'carbs', label: 'Total Carbo.', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  22 + { key: 'totalSugar', label: 'Sugars', defaultUnit: 'g', indent: true, dividerAfter: 'thin' },
  23 + { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g', indent: true, dividerAfter: 'thin' },
  24 + { key: 'protein', label: 'Protein', defaultUnit: 'g', labelBold: true, dividerAfter: 'double' },
  25 + { key: 'calcium', label: 'Calcium', defaultUnit: 'mg', dividerAfter: 'thin' },
  26 + { key: 'potassium', label: 'Potassium', defaultUnit: 'mg', dividerAfter: 'thin' },
  27 + { key: 'vitaminA', label: 'Vitamin A', defaultUnit: 'mg', dividerAfter: 'thin' },
  28 + { key: 'vitaminD', label: 'Vitamin D', defaultUnit: 'mg', dividerAfter: 'thin' },
  29 + { key: 'iron', label: 'Iron', defaultUnit: 'mg', dividerAfter: 'none' },
  30 +] as const;
  31 +
  32 +/** 含量 / %DV 列固定宽度(全表共用,保证各行垂直对齐) */
  33 +export const NUTRITION_AMOUNT_COL_WIDTH = '4rem';
  34 +export const NUTRITION_PCT_COL_WIDTH = '2.75rem';
  35 +
  36 +/** 图2 正文区字号(Servings ~ 底部维生素行) */
  37 +export const NUTRITION_BODY_FONT_SIZE = 12;
  38 +
  39 +export const DEFAULT_NUTRITION_FOOTER_NOTE =
  40 + '* Percent Daily Values are based on a 2000 calorie diet';
  41 +
  42 +export type NutritionFactsRowView = {
  43 + key: string;
  44 + label: string;
  45 + amountText: string;
  46 + dailyValueText: string;
  47 + labelBold: boolean;
  48 + indent: boolean;
  49 + dividerAfter: NutritionDivider;
  50 +};
  51 +
  52 +export type NutritionFactsViewModel = {
  53 + titleFontSize: number;
  54 + servingsLabel: string;
  55 + servingsValue: string;
  56 + servingSizeLabel: string;
  57 + servingSizeValue: string;
  58 + caloriesLabel: string;
  59 + caloriesValue: string;
  60 + caloriesAmountText: string;
  61 + rows: NutritionFactsRowView[];
  62 + footerNote: string;
  63 + ingredientsText: string;
  64 +};
  65 +
  66 +function cfgStr(cfg: Record<string, unknown>, keys: string[], fallback = ''): string {
  67 + for (const k of keys) {
  68 + const v = cfg[k];
  69 + if (v != null && String(v).trim() !== '') return String(v).trim();
  70 + }
  71 + return fallback;
  72 +}
  73 +
  74 +function cfgBool(cfg: Record<string, unknown>, keys: string[]): boolean {
  75 + for (const k of keys) {
  76 + const v = cfg[k];
  77 + if (v === true || v === 'true' || v === 1 || v === '1') return true;
  78 + if (v === false || v === 'false' || v === 0 || v === '0') return false;
  79 + }
  80 + return false;
  81 +}
  82 +
  83 +function fixedRows(cfg: Record<string, unknown>): Record<string, unknown>[] {
  84 + return Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : [];
  85 +}
  86 +
  87 +function rowFromFixed(cfg: Record<string, unknown>, key: string): Record<string, unknown> | undefined {
  88 + return fixedRows(cfg).find((r) => String(r.key ?? '').trim() === key);
  89 +}
  90 +
  91 +export function readNutritionLessThan(cfg: Record<string, unknown>, key: string): boolean {
  92 + const row = rowFromFixed(cfg, key);
  93 + if (row && row.lessThan != null) return cfgBool({ lessThan: row.lessThan }, ['lessThan']);
  94 + return cfgBool(cfg, [`${key}LessThan`, `${key}UseLessThan`]);
  95 +}
  96 +
  97 +export function nutritionFixedField(
  98 + cfg: Record<string, unknown>,
  99 + key: string,
  100 + field: 'value' | 'unit' | 'dailyValuePercent',
  101 +): string {
  102 + const row = rowFromFixed(cfg, key);
  103 + if (field === 'dailyValuePercent') {
  104 + const fromRow = row?.dailyValuePercent ?? row?.percent ?? row?.Percent;
  105 + if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim();
  106 + return cfgStr(cfg, [`${key}Percent`, `${key}DailyValue`, `${key}DailyValuePercent`], '');
  107 + }
  108 + if (field === 'unit') {
  109 + const fromRow = row?.unit;
  110 + if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim();
  111 + const def = NUTRITION_FACTS_LAYOUT_ROWS.find((r) => r.key === key);
  112 + return cfgStr(cfg, [`${key}Unit`], def?.defaultUnit ?? '');
  113 + }
  114 + const fromRow = row?.value;
  115 + if (fromRow != null && String(fromRow).trim() !== '') return String(fromRow).trim();
  116 + return cfgStr(cfg, [key, key.charAt(0).toUpperCase() + key.slice(1)], '');
  117 +}
  118 +
  119 +/** 格式化含量:仅 optional "<" 前缀;单位由录入时在 amount 中自行填写,不自动拼接 */
  120 +export function formatNutritionAmount(
  121 + value: string,
  122 + _unit?: string,
  123 + lessThan?: boolean,
  124 +): string {
  125 + const v = String(value ?? '').trim();
  126 + if (!v) return '';
  127 + const prefix = lessThan ? '<' : '';
  128 + return `${prefix}${v}`;
  129 +}
  130 +
  131 +export function formatNutritionDailyValue(raw: string): string {
  132 + const v = String(raw ?? '').trim();
  133 + if (!v) return '';
  134 + return v.endsWith('%') ? v : `${v}%`;
  135 +}
  136 +
  137 +function nutritionExtraRows(cfg: Record<string, unknown>): Array<{
  138 + id: string;
  139 + name: string;
  140 + value: string;
  141 + unit: string;
  142 +}> {
  143 + const raw = cfg.extraNutrients;
  144 + if (!Array.isArray(raw)) return [];
  145 + return raw.map((item, idx) => {
  146 + const row = item as Record<string, unknown>;
  147 + return {
  148 + id: String(row.id ?? `extra-${idx}`),
  149 + name: String(row.name ?? ''),
  150 + value: String(row.value ?? ''),
  151 + unit: String(row.unit ?? ''),
  152 + };
  153 + });
  154 +}
  155 +
  156 +/** 从 config 构建渲染模型(Web 画布 / App canvas 共用逻辑) */
  157 +export function buildNutritionFactsViewModel(cfg: Record<string, unknown>): NutritionFactsViewModel {
  158 + const titleFontSize = Number(cfg.nutritionTitleFontSize ?? cfg.NutritionTitleFontSize ?? 16) || 16;
  159 + const servingsValue = cfgStr(cfg, ['servings', 'servingsPerContainer', 'ServingsPerContainer']);
  160 + const servingSizeValue = cfgStr(cfg, ['servingSize', 'ServingSize']);
  161 + const caloriesRaw = nutritionFixedField(cfg, 'calories', 'value') || cfgStr(cfg, ['calories', 'Calories']);
  162 + const caloriesLessThan = readNutritionLessThan(cfg, 'calories');
  163 + const layoutByKey = new Map(NUTRITION_FACTS_LAYOUT_ROWS.map((r) => [r.key, r]));
  164 + const rows: NutritionFactsRowView[] = [];
  165 + const seen = new Set<string>();
  166 +
  167 + for (const def of NUTRITION_FACTS_LAYOUT_ROWS) {
  168 + seen.add(def.key);
  169 + const value = nutritionFixedField(cfg, def.key, 'value');
  170 + const lessThan = readNutritionLessThan(cfg, def.key);
  171 + const pct = nutritionFixedField(cfg, def.key, 'dailyValuePercent');
  172 + rows.push({
  173 + key: def.key,
  174 + label: String(rowFromFixed(cfg, def.key)?.label ?? def.label),
  175 + amountText: formatNutritionAmount(value, '', lessThan),
  176 + dailyValueText: formatNutritionDailyValue(pct),
  177 + labelBold: def.labelBold ?? false,
  178 + indent: def.indent ?? false,
  179 + dividerAfter: def.dividerAfter ?? 'none',
  180 + });
  181 + }
  182 +
  183 + for (const ex of nutritionExtraRows(cfg)) {
  184 + const key = `extra:${ex.id}`;
  185 + if (seen.has(key)) continue;
  186 + const lessThan = cfgBool(cfg, [`extra:${ex.id}:lessThan`]);
  187 + rows.push({
  188 + key,
  189 + label: ex.name.trim() || 'Other',
  190 + amountText: formatNutritionAmount(ex.value, '', lessThan),
  191 + dailyValueText: formatNutritionDailyValue(
  192 + cfgStr(cfg, [`extra:${ex.id}:percent`, `extra:${ex.id}:dailyValuePercent`]),
  193 + ),
  194 + labelBold: false,
  195 + indent: false,
  196 + dividerAfter: 'thin',
  197 + });
  198 + }
  199 +
  200 + for (const fr of fixedRows(cfg)) {
  201 + const key = String(fr.key ?? '').trim();
  202 + if (!key || seen.has(key)) continue;
  203 + const def = layoutByKey.get(key);
  204 + const value = String(fr.value ?? '').trim();
  205 + const lessThan = cfgBool({ lessThan: fr.lessThan }, ['lessThan']);
  206 + rows.push({
  207 + key,
  208 + label: String(fr.label ?? def?.label ?? key),
  209 + amountText: formatNutritionAmount(value, '', lessThan),
  210 + dailyValueText: formatNutritionDailyValue(String(fr.dailyValuePercent ?? fr.percent ?? '')),
  211 + labelBold: def?.labelBold ?? false,
  212 + indent: def?.indent ?? false,
  213 + dividerAfter: def?.dividerAfter ?? 'thin',
  214 + });
  215 + }
  216 +
  217 + return {
  218 + titleFontSize,
  219 + servingsLabel: cfgStr(cfg, ['servingsLabel'], 'Servings'),
  220 + servingsValue,
  221 + servingSizeLabel: cfgStr(cfg, ['servingSizeLabel'], 'Serve size'),
  222 + servingSizeValue,
  223 + caloriesLabel: cfgStr(cfg, ['caloriesLabel'], 'Calories'),
  224 + caloriesValue: caloriesRaw,
  225 + caloriesAmountText: formatNutritionAmount(caloriesRaw, '', caloriesLessThan),
  226 + rows,
  227 + footerNote: cfgStr(cfg, ['nutritionFooterNote', 'footerNote'], DEFAULT_NUTRITION_FOOTER_NOTE),
  228 + ingredientsText: cfgStr(cfg, ['ingredientsText', 'ingredients', 'IngredientsText']),
  229 + };
  230 +}
美国版/Food Labeling Management Platform/src/lib/nutritionManualEntry.ts
1 import type { LabelElement } from "../types/labelTemplate"; 1 import type { LabelElement } from "../types/labelTemplate";
2 import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate"; 2 import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate";
  3 +import { NUTRITION_FACTS_LAYOUT_ROWS, DEFAULT_NUTRITION_FOOTER_NOTE } from "./nutritionFactsLayout";
3 4
4 /** 批量表 / 与 elementId 拼接的字段名分隔(避免与普通 element id 冲突) */ 5 /** 批量表 / 与 elementId 拼接的字段名分隔(避免与普通 element id 冲突) */
5 export const NUTRITION_FIELD_COMPOSITE_SEP = "###nut###"; 6 export const NUTRITION_FIELD_COMPOSITE_SEP = "###nut###";
@@ -11,6 +12,7 @@ export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: s @@ -11,6 +12,7 @@ export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: s
11 export type NutritionManualFieldSpec = { 12 export type NutritionManualFieldSpec = {
12 subKey: string; 13 subKey: string;
13 columnLabel: string; 14 columnLabel: string;
  15 + inputType?: "text" | "checkbox";
14 }; 16 };
15 17
16 function nutritionExtraRowsFromCfg(cfg: Record<string, unknown>): Array<{ 18 function nutritionExtraRowsFromCfg(cfg: Record<string, unknown>): Array<{
@@ -33,7 +35,7 @@ function nutritionExtraRowsFromCfg(cfg: Record&lt;string, unknown&gt;): Array&lt;{ @@ -33,7 +35,7 @@ function nutritionExtraRowsFromCfg(cfg: Record&lt;string, unknown&gt;): Array&lt;{
33 } 35 }
34 36
35 function fixedLabelForKey(key: string): string { 37 function fixedLabelForKey(key: string): string {
36 - const hit = NUTRITION_FIXED_ITEMS.find((x) => x.key === key); 38 + const hit = NUTRITION_FACTS_LAYOUT_ROWS.find((x) => x.key === key) ?? NUTRITION_FIXED_ITEMS.find((x) => x.key === key);
37 return hit?.label ?? key; 39 return hit?.label ?? key;
38 } 40 }
39 41
@@ -52,40 +54,50 @@ function fakeNutritionElement(cfg: Record&lt;string, unknown&gt;): LabelElement { @@ -52,40 +54,50 @@ function fakeNutritionElement(cfg: Record&lt;string, unknown&gt;): LabelElement {
52 } 54 }
53 55
54 /** 56 /**
55 - * 模板中每个 NUTRITION 元素在「录入 / 批量表」中展开的列(表头为营养成分名称)。  
56 - * 按模板固定表结构 + 自定义行出列;数值在 Label / Bulk Add 录入,不要求模板 config 里已有 value。 57 + * 模板中每个 NUTRITION 元素在「录入 / 批量表」中展开的列。
  58 + * 每行营养素固定两列录入:amount(含量含单位,自行填写如 11g)+ %DV。
57 */ 59 */
58 export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] { 60 export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] {
59 if (canonicalElementType(el.type) !== "NUTRITION") return []; 61 if (canonicalElementType(el.type) !== "NUTRITION") return [];
60 const cfg = (el.config ?? {}) as Record<string, unknown>; 62 const cfg = (el.config ?? {}) as Record<string, unknown>;
61 const specs: NutritionManualFieldSpec[] = [ 63 const specs: NutritionManualFieldSpec[] = [
62 - { subKey: "servingsPerContainer", columnLabel: "Servings Per Container" },  
63 - { subKey: "servingSize", columnLabel: "Serving Size" }, 64 + { subKey: "servingsPerContainer", columnLabel: "Servings" },
  65 + { subKey: "servingSize", columnLabel: "Serve size" },
64 { subKey: "calories", columnLabel: "Calories" }, 66 { subKey: "calories", columnLabel: "Calories" },
65 ]; 67 ];
66 68
67 const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : []; 69 const fixedArr = Array.isArray(cfg.fixedNutrients) ? (cfg.fixedNutrients as Record<string, unknown>[]) : [];
68 - const seen = new Set<string>();  
69 - if (fixedArr.length > 0) {  
70 - for (const row of fixedArr) {  
71 - const key = String(row.key ?? "").trim();  
72 - if (!key || seen.has(key)) continue;  
73 - seen.add(key);  
74 - const label = String(row.label ?? "").trim() || fixedLabelForKey(key);  
75 - specs.push({ subKey: key, columnLabel: label });  
76 - }  
77 - } else {  
78 - for (const item of NUTRITION_FIXED_ITEMS) {  
79 - specs.push({ subKey: item.key, columnLabel: item.label });  
80 - } 70 + const labelByKey = new Map<string, string>();
  71 + for (const item of NUTRITION_FACTS_LAYOUT_ROWS) {
  72 + labelByKey.set(item.key, item.label);
  73 + }
  74 + for (const row of fixedArr) {
  75 + const key = String(row.key ?? "").trim();
  76 + if (!key) continue;
  77 + if (!labelByKey.has(key)) labelByKey.set(key, String(row.label ?? key));
  78 + else if (row.label) labelByKey.set(key, String(row.label));
  79 + }
  80 +
  81 + const keysInOrder: string[] = NUTRITION_FACTS_LAYOUT_ROWS.map((r) => r.key);
  82 + for (const row of fixedArr) {
  83 + const key = String(row.key ?? "").trim();
  84 + if (key && !keysInOrder.includes(key)) keysInOrder.push(key);
  85 + }
  86 +
  87 + for (const key of keysInOrder) {
  88 + const label = labelByKey.get(key) ?? fixedLabelForKey(key);
  89 + specs.push({ subKey: key, columnLabel: `${label} (amount)` });
  90 + specs.push({ subKey: `${key}Percent`, columnLabel: `${label} (% DV)` });
81 } 91 }
82 92
83 for (const ex of nutritionExtraRowsFromCfg(cfg)) { 93 for (const ex of nutritionExtraRowsFromCfg(cfg)) {
84 const id = String(ex.id ?? "").trim(); 94 const id = String(ex.id ?? "").trim();
85 if (!id) continue; 95 if (!id) continue;
86 const name = ex.name.trim() || "Other"; 96 const name = ex.name.trim() || "Other";
87 - specs.push({ subKey: `extra:${id}:value`, columnLabel: name }); 97 + specs.push({ subKey: `extra:${id}:value`, columnLabel: `${name} (amount)` });
  98 + specs.push({ subKey: `extra:${id}:percent`, columnLabel: `${name} (% DV)` });
88 } 99 }
  100 +
89 return specs; 101 return specs;
90 } 102 }
91 103
@@ -98,7 +110,7 @@ export function nutritionManualValuesFromTemplateConfig(_el: LabelElement): Reco @@ -98,7 +110,7 @@ export function nutritionManualValuesFromTemplateConfig(_el: LabelElement): Reco
98 const specs = listNutritionManualFieldSpecs(_el); 110 const specs = listNutritionManualFieldSpecs(_el);
99 const out: Record<string, string> = {}; 111 const out: Record<string, string> = {};
100 for (const s of specs) { 112 for (const s of specs) {
101 - out[s.subKey] = ""; 113 + out[s.subKey] = s.inputType === "checkbox" ? "false" : "";
102 } 114 }
103 return out; 115 return out;
104 } 116 }
@@ -108,7 +120,7 @@ function pickManual(manual: Record&lt;string, string&gt;, subKey: string): string { @@ -108,7 +120,7 @@ function pickManual(manual: Record&lt;string, string&gt;, subKey: string): string {
108 } 120 }
109 121
110 /** 122 /**
111 - * 模板编辑器持久化:仅保留表结构(单位、自定义行名称),清除所有展示数值。 123 + * 模板编辑器持久化:仅保留表结构(单位、自定义行名称、页脚文案),清除所有展示数值。
112 */ 124 */
113 export function sanitizeNutritionTemplateConfig( 125 export function sanitizeNutritionTemplateConfig(
114 cfg: Record<string, unknown>, 126 cfg: Record<string, unknown>,
@@ -120,27 +132,36 @@ export function sanitizeNutritionTemplateConfig( @@ -120,27 +132,36 @@ export function sanitizeNutritionTemplateConfig(
120 delete out.Calories; 132 delete out.Calories;
121 delete out.ServingsPerContainer; 133 delete out.ServingsPerContainer;
122 delete out.ServingSize; 134 delete out.ServingSize;
  135 + delete out.ingredientsText;
  136 + delete out.IngredientsText;
  137 + for (const k of Object.keys(out)) {
  138 + if (k.endsWith("Percent")) delete out[k];
  139 + }
123 140
124 const baseFixed = Array.isArray(out.fixedNutrients) 141 const baseFixed = Array.isArray(out.fixedNutrients)
125 ? (out.fixedNutrients as Record<string, unknown>[]) 142 ? (out.fixedNutrients as Record<string, unknown>[])
126 : []; 143 : [];
127 - const fixedArr = NUTRITION_FIXED_ITEMS.map((item) => { 144 + const fixedArr = NUTRITION_FACTS_LAYOUT_ROWS.map((item) => {
128 const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === item.key); 145 const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === item.key);
129 const unit = String(baseRow?.unit ?? item.defaultUnit ?? "").trim(); 146 const unit = String(baseRow?.unit ?? item.defaultUnit ?? "").trim();
  147 + const lessThan = Boolean(baseRow?.lessThan ?? out[`${item.key}LessThan`]);
130 return { 148 return {
131 key: item.key, 149 key: item.key,
132 label: String(baseRow?.label ?? item.label), 150 label: String(baseRow?.label ?? item.label),
133 value: "", 151 value: "",
134 unit, 152 unit,
  153 + dailyValuePercent: "",
  154 + lessThan,
135 }; 155 };
136 }); 156 });
137 out.fixedNutrients = fixedArr; 157 out.fixedNutrients = fixedArr;
138 158
139 - for (const item of NUTRITION_FIXED_ITEMS) { 159 + for (const item of NUTRITION_FACTS_LAYOUT_ROWS) {
140 out[item.key] = ""; 160 out[item.key] = "";
141 - const unit = fixedArr.find((r) => r.key === item.key)?.unit;  
142 - if (unit) out[`${item.key}Unit`] = unit; 161 + const row = fixedArr.find((r) => r.key === item.key);
  162 + if (row?.unit) out[`${item.key}Unit`] = row.unit;
143 else delete out[`${item.key}Unit`]; 163 else delete out[`${item.key}Unit`];
  164 + out[`${item.key}LessThan`] = Boolean(row?.lessThan);
144 } 165 }
145 166
146 out.extraNutrients = nutritionExtraRowsFromCfg(out).map((ex) => ({ 167 out.extraNutrients = nutritionExtraRowsFromCfg(out).map((ex) => ({
@@ -149,6 +170,10 @@ export function sanitizeNutritionTemplateConfig( @@ -149,6 +170,10 @@ export function sanitizeNutritionTemplateConfig(
149 value: "", 170 value: "",
150 unit: ex.unit, 171 unit: ex.unit,
151 })); 172 }));
  173 +
  174 + if (!String(out.nutritionFooterNote ?? "").trim()) {
  175 + out.nutritionFooterNote = DEFAULT_NUTRITION_FOOTER_NOTE;
  176 + }
152 return out; 177 return out;
153 } 178 }
154 179
@@ -167,7 +192,6 @@ export function sanitizeNutritionElementsForTemplateEditor( @@ -167,7 +192,6 @@ export function sanitizeNutritionElementsForTemplateEditor(
167 192
168 /** 193 /**
169 * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。 194 * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。
170 - * 输出中仅保留模板已声明的营养成分行,与 listNutritionManualFieldSpecs 一致。  
171 */ 195 */
172 export function mergeNutritionManualIntoConfig( 196 export function mergeNutritionManualIntoConfig(
173 baseCfg: Record<string, unknown>, 197 baseCfg: Record<string, unknown>,
@@ -184,48 +208,48 @@ export function mergeNutritionManualIntoConfig( @@ -184,48 +208,48 @@ export function mergeNutritionManualIntoConfig(
184 delete cfg.calories; 208 delete cfg.calories;
185 delete cfg.Calories; 209 delete cfg.Calories;
186 } 210 }
187 - } else {  
188 - delete cfg.calories;  
189 - delete cfg.Calories;  
190 } 211 }
191 212
192 if (specSubKeys.has("servingsPerContainer")) { 213 if (specSubKeys.has("servingsPerContainer")) {
193 cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer"); 214 cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer");
194 - } else {  
195 - cfg.servingsPerContainer = "";  
196 - delete cfg.ServingsPerContainer;  
197 } 215 }
198 -  
199 if (specSubKeys.has("servingSize")) { 216 if (specSubKeys.has("servingSize")) {
200 cfg.servingSize = pickManual(manual, "servingSize"); 217 cfg.servingSize = pickManual(manual, "servingSize");
201 - } else {  
202 - cfg.servingSize = "";  
203 - delete cfg.ServingSize;  
204 } 218 }
205 219
206 const baseFixed = Array.isArray(baseCfg.fixedNutrients) 220 const baseFixed = Array.isArray(baseCfg.fixedNutrients)
207 ? (baseCfg.fixedNutrients as Record<string, unknown>[]) 221 ? (baseCfg.fixedNutrients as Record<string, unknown>[])
208 : []; 222 : [];
209 const fixedArr: Record<string, unknown>[] = []; 223 const fixedArr: Record<string, unknown>[] = [];
  224 +
210 for (const s of specs) { 225 for (const s of specs) {
211 - if (["calories", "servingsPerContainer", "servingSize"].includes(s.subKey)) continue; 226 + if (["calories", "servingsPerContainer", "servingSize"].includes(s.subKey)) {
  227 + continue;
  228 + }
212 if (s.subKey.startsWith("extra:")) continue; 229 if (s.subKey.startsWith("extra:")) continue;
213 - const v = pickManual(manual, s.subKey);  
214 - const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === s.subKey);  
215 - const unit = String(  
216 - baseRow?.unit ?? NUTRITION_FIXED_ITEMS.find((x) => x.key === s.subKey)?.defaultUnit ?? "",  
217 - );  
218 - const label = String(baseRow?.label ?? fixedLabelForKey(s.subKey));  
219 - fixedArr.push({ key: s.subKey, label, value: v, unit });  
220 - /** LabelCanvas 先读顶层 key(如 fat),须与手动录入同步,否则会一直显示模板默认值 */  
221 - if (v) {  
222 - cfg[s.subKey] = v;  
223 - if (unit) cfg[`${s.subKey}Unit`] = unit;  
224 - else delete cfg[`${s.subKey}Unit`];  
225 - } else {  
226 - delete cfg[s.subKey];  
227 - delete cfg[`${s.subKey}Unit`]; 230 + if (s.subKey.endsWith("Percent")) continue;
  231 +
  232 + const key = s.subKey;
  233 + const v = pickManual(manual, key);
  234 + const pct = pickManual(manual, `${key}Percent`);
  235 + const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === key);
  236 + const lessThan = Boolean(baseRow?.lessThan ?? baseCfg[`${key}LessThan`]);
  237 + const label = String(baseRow?.label ?? fixedLabelForKey(key));
  238 + fixedArr.push({
  239 + key,
  240 + label,
  241 + value: v,
  242 + unit: "",
  243 + dailyValuePercent: pct,
  244 + lessThan,
  245 + });
  246 + if (v) cfg[key] = v;
  247 + else {
  248 + delete cfg[key];
  249 + delete cfg[`${key}Unit`];
228 } 250 }
  251 + cfg[`${key}Percent`] = pct;
  252 + cfg[`${key}LessThan`] = lessThan;
229 } 253 }
230 cfg.fixedNutrients = fixedArr; 254 cfg.fixedNutrients = fixedArr;
231 255
@@ -240,6 +264,8 @@ export function mergeNutritionManualIntoConfig( @@ -240,6 +264,8 @@ export function mergeNutritionManualIntoConfig(
240 value: pickManual(manual, s.subKey), 264 value: pickManual(manual, s.subKey),
241 unit: String(base?.unit ?? "").trim(), 265 unit: String(base?.unit ?? "").trim(),
242 }); 266 });
  267 + cfg[`extra:${id}:percent`] = pickManual(manual, `extra:${id}:percent`);
  268 + cfg[`extra:${id}:lessThan`] = Boolean(baseCfg[`extra:${id}:lessThan`]);
243 } 269 }
244 cfg.extraNutrients = newExtras; 270 cfg.extraNutrients = newExtras;
245 return cfg; 271 return cfg;
美国版/Food Labeling Management Platform/src/main.tsx
@@ -6,6 +6,7 @@ @@ -6,6 +6,7 @@
6 import "./styles/category-appearance-toggle.css"; 6 import "./styles/category-appearance-toggle.css";
7 import "./styles/form-dialog.css"; 7 import "./styles/form-dialog.css";
8 import "./styles/fonts.css"; 8 import "./styles/fonts.css";
  9 + import "./styles/label-editor-fonts.css";
9 10
10 createRoot(document.getElementById("root")!).render(<App />); 11 createRoot(document.getElementById("root")!).render(<App />);
11 12
12 \ No newline at end of file 13 \ No newline at end of file
美国版/Food Labeling Management Platform/src/services/labelTemplateService.ts
@@ -65,6 +65,7 @@ function normalizeTemplateElements(list: unknown): LabelElement[] { @@ -65,6 +65,7 @@ function normalizeTemplateElements(list: unknown): LabelElement[] {
65 typeAdd: typeof typeAddRaw === "string" ? typeAddRaw.trim() : undefined, 65 typeAdd: typeof typeAddRaw === "string" ? typeAddRaw.trim() : undefined,
66 inputKey: typeof ik === "string" ? ik : e.inputKey ?? null, 66 inputKey: typeof ik === "string" ? ik : e.inputKey ?? null,
67 libraryCategory, 67 libraryCategory,
  68 + border: normalizeTemplateBorder(e.border ?? e.Border ?? e.BorderType ?? e.borderType),
68 config: stripLabelConfigPrefixes(rawCfg) as LabelElement["config"], 69 config: stripLabelConfigPrefixes(rawCfg) as LabelElement["config"],
69 } as LabelElement; 70 } as LabelElement;
70 }); 71 });
美国版/Food Labeling Management Platform/src/styles/label-editor-fonts.css 0 → 100644
  1 +/**
  2 + * 标签编辑器可选字体(OFL 授权,来源 @fontsource / Google Fonts)
  3 + * Roboto、Open Sans、Lato、Tinos、Roboto Mono
  4 + */
  5 +
  6 +@font-face {
  7 + font-family: 'Roboto';
  8 + src: url('../assets/fonts/roboto/roboto-latin-400-normal.woff2') format('woff2');
  9 + font-weight: 400;
  10 + font-style: normal;
  11 + font-display: swap;
  12 +}
  13 +
  14 +@font-face {
  15 + font-family: 'Roboto';
  16 + src: url('../assets/fonts/roboto/roboto-latin-700-normal.woff2') format('woff2');
  17 + font-weight: 700;
  18 + font-style: normal;
  19 + font-display: swap;
  20 +}
  21 +
  22 +@font-face {
  23 + font-family: 'Roboto';
  24 + src: url('../assets/fonts/roboto/roboto-latin-400-italic.woff2') format('woff2');
  25 + font-weight: 400;
  26 + font-style: italic;
  27 + font-display: swap;
  28 +}
  29 +
  30 +@font-face {
  31 + font-family: 'Open Sans';
  32 + src: url('../assets/fonts/open-sans/open-sans-latin-400-normal.woff2') format('woff2');
  33 + font-weight: 400;
  34 + font-style: normal;
  35 + font-display: swap;
  36 +}
  37 +
  38 +@font-face {
  39 + font-family: 'Open Sans';
  40 + src: url('../assets/fonts/open-sans/open-sans-latin-700-normal.woff2') format('woff2');
  41 + font-weight: 700;
  42 + font-style: normal;
  43 + font-display: swap;
  44 +}
  45 +
  46 +@font-face {
  47 + font-family: 'Open Sans';
  48 + src: url('../assets/fonts/open-sans/open-sans-latin-400-italic.woff2') format('woff2');
  49 + font-weight: 400;
  50 + font-style: italic;
  51 + font-display: swap;
  52 +}
  53 +
  54 +@font-face {
  55 + font-family: 'Lato';
  56 + src: url('../assets/fonts/lato/lato-latin-400-normal.woff2') format('woff2');
  57 + font-weight: 400;
  58 + font-style: normal;
  59 + font-display: swap;
  60 +}
  61 +
  62 +@font-face {
  63 + font-family: 'Lato';
  64 + src: url('../assets/fonts/lato/lato-latin-700-normal.woff2') format('woff2');
  65 + font-weight: 700;
  66 + font-style: normal;
  67 + font-display: swap;
  68 +}
  69 +
  70 +@font-face {
  71 + font-family: 'Lato';
  72 + src: url('../assets/fonts/lato/lato-latin-400-italic.woff2') format('woff2');
  73 + font-weight: 400;
  74 + font-style: italic;
  75 + font-display: swap;
  76 +}
  77 +
  78 +@font-face {
  79 + font-family: 'Tinos';
  80 + src: url('../assets/fonts/tinos/tinos-latin-400-normal.woff2') format('woff2');
  81 + font-weight: 400;
  82 + font-style: normal;
  83 + font-display: swap;
  84 +}
  85 +
  86 +@font-face {
  87 + font-family: 'Tinos';
  88 + src: url('../assets/fonts/tinos/tinos-latin-700-normal.woff2') format('woff2');
  89 + font-weight: 700;
  90 + font-style: normal;
  91 + font-display: swap;
  92 +}
  93 +
  94 +@font-face {
  95 + font-family: 'Tinos';
  96 + src: url('../assets/fonts/tinos/tinos-latin-400-italic.woff2') format('woff2');
  97 + font-weight: 400;
  98 + font-style: italic;
  99 + font-display: swap;
  100 +}
  101 +
  102 +@font-face {
  103 + font-family: 'Roboto Mono';
  104 + src: url('../assets/fonts/roboto-mono/roboto-mono-latin-400-normal.woff2') format('woff2');
  105 + font-weight: 400;
  106 + font-style: normal;
  107 + font-display: swap;
  108 +}
  109 +
  110 +@font-face {
  111 + font-family: 'Roboto Mono';
  112 + src: url('../assets/fonts/roboto-mono/roboto-mono-latin-700-normal.woff2') format('woff2');
  113 + font-weight: 700;
  114 + font-style: normal;
  115 + font-display: swap;
  116 +}
  117 +
  118 +@font-face {
  119 + font-family: 'Roboto Mono';
  120 + src: url('../assets/fonts/roboto-mono/roboto-mono-latin-400-italic.woff2') format('woff2');
  121 + font-weight: 400;
  122 + font-style: italic;
  123 + font-display: swap;
  124 +}
美国版/Food Labeling Management Platform/src/types/labelTemplate.ts
@@ -48,6 +48,8 @@ export type ElementType = @@ -48,6 +48,8 @@ export type ElementType =
48 | 'NUTRITION'; 48 | 'NUTRITION';
49 export type ElementTypeValue = ElementType | string; 49 export type ElementTypeValue = ElementType | string;
50 50
  51 +import { NUTRITION_FACTS_LAYOUT_ROWS, DEFAULT_NUTRITION_FOOTER_NOTE } from '../lib/nutritionFactsLayout';
  52 +
51 export interface NutritionFixedItem { 53 export interface NutritionFixedItem {
52 key: string; 54 key: string;
53 label: string; 55 label: string;
@@ -61,21 +63,14 @@ export interface NutritionExtraItem { @@ -61,21 +63,14 @@ export interface NutritionExtraItem {
61 unit: string; 63 unit: string;
62 } 64 }
63 65
64 -export const NUTRITION_FIXED_ITEMS: readonly NutritionFixedItem[] = [  
65 - { key: 'fat', label: 'Total Fat', defaultUnit: 'g' },  
66 - { key: 'saturatedFat', label: 'Saturated Fat', defaultUnit: 'g' },  
67 - { key: 'transFat', label: 'Trans Fat', defaultUnit: 'g' },  
68 - { key: 'cholesterol', label: 'Cholesterol', defaultUnit: 'mg' },  
69 - { key: 'sodium', label: 'Sodium', defaultUnit: 'mg' },  
70 - { key: 'carbs', label: 'Total Carbohydrates', defaultUnit: 'g' },  
71 - { key: 'dietaryFiber', label: 'Dietary Fiber', defaultUnit: 'g' },  
72 - { key: 'totalSugar', label: 'Total Sugar', defaultUnit: 'g' },  
73 - { key: 'protein', label: 'Protein', defaultUnit: 'g' },  
74 - { key: 'vitaminA', label: 'Vitamin A', defaultUnit: 'mcg' },  
75 - { key: 'vitaminC', label: 'Vitamin C', defaultUnit: 'mg' },  
76 - { key: 'calcium', label: 'Calcium', defaultUnit: 'mg' },  
77 - { key: 'iron', label: 'Iron', defaultUnit: 'mg' },  
78 -] as const; 66 +/** @deprecated 使用 NUTRITION_FACTS_LAYOUT_ROWS;保留别名供旧代码引用 */
  67 +export const NUTRITION_FIXED_ITEMS: readonly NutritionFixedItem[] = NUTRITION_FACTS_LAYOUT_ROWS.map(
  68 + (row) => ({
  69 + key: row.key,
  70 + label: row.label,
  71 + defaultUnit: row.defaultUnit,
  72 + }),
  73 +);
79 74
80 /** Left panel section titles (Elements panel); persisted per element as part of libraryCategory logic */ 75 /** Left panel section titles (Elements panel); persisted per element as part of libraryCategory logic */
81 export type ElementLibraryCategory = 76 export type ElementLibraryCategory =
@@ -190,6 +185,186 @@ export function isComposedLibraryCategoryValue(s: string): boolean { @@ -190,6 +185,186 @@ export function isComposedLibraryCategoryValue(s: string): boolean {
190 return COMPOSED_LIBRARY_CATEGORY_RE.test(s.trim()); 185 return COMPOSED_LIBRARY_CATEGORY_RE.test(s.trim());
191 } 186 }
192 187
  188 +/** 左侧面板 Template 与 Label 均提供的控件名(与 Elements 面板 label 一致) */
  189 +export const PALETTE_LABELS_SHARED_TEMPLATE_AND_LABEL: ReadonlySet<string> = new Set([
  190 + "Text",
  191 + "QR Code",
  192 + "Barcode",
  193 + "Price",
  194 + "Image",
  195 +]);
  196 +
  197 +const TYPE_ADD_PREFIX_TO_LIBRARY_GROUP: Record<string, ElementLibraryCategory> = {
  198 + template: "Template",
  199 + label: "Label",
  200 + auto: "Auto-generated",
  201 + print: "Print input",
  202 +};
  203 +
  204 +/** typeAdd / libraryCategory 后缀 slug → 面板英文名(大小写与左侧控件一致) */
  205 +const PALETTE_LABEL_DISPLAY_BY_KEY: Record<string, string> = {
  206 + text: "Text",
  207 + "qr code": "QR Code",
  208 + qrcode: "QR Code",
  209 + barcode: "Barcode",
  210 + price: "Price",
  211 + image: "Image",
  212 + logo: "Logo",
  213 + "blank space": "Blank Space",
  214 + blankspace: "Blank Space",
  215 + "label name": "Label Name",
  216 + labelname: "Label Name",
  217 + "nutrition facts": "Nutrition Facts",
  218 + "duration date": "Duration Date",
  219 + "duration time": "Duration Time",
  220 + duration: "Duration",
  221 + "label type": "Label Type",
  222 + "how-to": "How-to",
  223 + howto: "How-to",
  224 + "expiration alert": "Expiration Alert",
  225 + company: "Company",
  226 + employee: "Employee",
  227 + "current date": "Current Date",
  228 + "current time": "Current Time",
  229 + "label id": "Label ID",
  230 + labelid: "Label ID",
  231 + weight: "Weight",
  232 + number: "Number",
  233 + "date & time": "Date & Time",
  234 + datetime: "Date & Time",
  235 + "multiple options": "Multiple Options",
  236 +};
  237 +
  238 +function normalizePaletteLabelForKey(raw: string): string {
  239 + return String(raw ?? "").trim().toLowerCase().replace(/\s+/g, " ");
  240 +}
  241 +
  242 +/** 将 typeAdd 后缀等归一为与左侧面板一致的大小写 */
  243 +function canonicalizePaletteLabel(raw: string): string {
  244 + const trimmed = String(raw ?? "").trim();
  245 + if (!trimmed) return trimmed;
  246 + const byKey = PALETTE_LABEL_DISPLAY_BY_KEY[normalizePaletteLabelForKey(trimmed)];
  247 + if (byKey) return byKey;
  248 + const bySlug = PALETTE_LABEL_DISPLAY_BY_KEY[slugPaletteItemLabel(trimmed)];
  249 + if (bySlug) return bySlug;
  250 + return trimmed;
  251 +}
  252 +
  253 +/** 从 typeAdd / libraryCategory 解析面板分组与控件英文名 */
  254 +export function parseComposedLibraryTypeAdd(raw: string): {
  255 + group: ElementLibraryCategory;
  256 + paletteLabel: string;
  257 +} | null {
  258 + const m = String(raw ?? "").trim().match(/^(template|label|auto|print)_(.+)$/i);
  259 + if (!m) return null;
  260 + const group = TYPE_ADD_PREFIX_TO_LIBRARY_GROUP[m[1].toLowerCase()];
  261 + const paletteLabel = canonicalizePaletteLabel(m[2].trim());
  262 + if (!group || !paletteLabel) return null;
  263 + return { group, paletteLabel };
  264 +}
  265 +
  266 +/** 从 typeAdd / libraryCategory 解析面板分组与控件英文名 */
  267 +export function parseElementPaletteContext(el: LabelElement): {
  268 + group: ElementLibraryCategory;
  269 + paletteLabel: string;
  270 +} | null {
  271 + const fromComposed =
  272 + parseComposedLibraryTypeAdd(resolvedTypeAddForPersist(el)) ??
  273 + parseComposedLibraryTypeAdd(resolvedLibraryCategoryForPersist(el));
  274 + if (fromComposed) return fromComposed;
  275 +
  276 + const rawType = String(el.type ?? "").trim();
  277 + if (COMPOSED_ELEMENT_TYPE_RE.test(rawType)) {
  278 + const fromType = parseComposedLibraryTypeAdd(rawType);
  279 + if (fromType) return fromType;
  280 + }
  281 +
  282 + const rawLc = el.libraryCategory?.trim() ?? "";
  283 + if (rawLc && isElementLibraryCategory(rawLc)) {
  284 + return {
  285 + group: toCanonicalElementLibraryCategory(rawLc),
  286 + paletteLabel: canonicalizePaletteLabel(inferPaletteEnglishLabel(el)),
  287 + };
  288 + }
  289 +
  290 + return null;
  291 +}
  292 +
  293 +function buildElementEditorDisplayName(
  294 + group: ElementLibraryCategory,
  295 + paletteLabel: string,
  296 + slug: string,
  297 + showOrdinal: boolean,
  298 +): string {
  299 + const ordinal = showOrdinal ? elementNameOrdinalSuffix(slug, paletteLabel) : "";
  300 + const categorySuffix =
  301 + (group === "Template" || group === "Label") &&
  302 + PALETTE_LABELS_SHARED_TEMPLATE_AND_LABEL.has(paletteLabel)
  303 + ? ` (${elementLibraryCategoryPanelTitle(group)})`
  304 + : "";
  305 + return `${paletteLabel}${categorySuffix}${ordinal}`;
  306 +}
  307 +
  308 +/** elementName ordinal suffix: price → " 1", price2 → " 2" (after panel source suffix) */
  309 +function elementNameOrdinalSuffix(slug: string, paletteLabel: string): string {
  310 + const base = slugPaletteItemLabel(paletteLabel);
  311 + if (!slug || !base) return "";
  312 + if (slug.toLowerCase() === base) return " 1";
  313 + const re = new RegExp(`^${escapeRegExp(base)}(\\d+)$`, "i");
  314 + const m = slug.match(re);
  315 + return m ? ` ${m[1]}` : "";
  316 +}
  317 +
  318 +function resolveElementPaletteIdentity(el: LabelElement): {
  319 + group: ElementLibraryCategory;
  320 + paletteLabel: string;
  321 +} {
  322 + const parsed = parseElementPaletteContext(el);
  323 + if (parsed) return parsed;
  324 + return {
  325 + group: inferElementLibraryCategory(el),
  326 + paletteLabel: canonicalizePaletteLabel(inferPaletteEnglishLabel(el)),
  327 + };
  328 +}
  329 +
  330 +function elementPaletteSiblings(el: LabelElement, allElements: LabelElement[]): LabelElement[] {
  331 + const identity = resolveElementPaletteIdentity(el);
  332 + return allElements.filter((other) => {
  333 + const otherIdentity = resolveElementPaletteIdentity(other);
  334 + return (
  335 + otherIdentity.group === identity.group &&
  336 + otherIdentity.paletteLabel === identity.paletteLabel
  337 + );
  338 + });
  339 +}
  340 +
  341 +/**
  342 + * 编辑器属性面板、模板列表 Contents:控件展示名。
  343 + * 同面板同类控件仅 1 个时不加序号;多个时 `Text (For Template) 1` / `Text (For Template) 2`。
  344 + */
  345 +export function elementEditorDisplayName(
  346 + el: LabelElement,
  347 + allElements?: LabelElement[],
  348 +): string {
  349 + const slug = (el.elementName ?? "").trim();
  350 + const { group, paletteLabel } = resolveElementPaletteIdentity(el);
  351 + const siblings = allElements?.length ? elementPaletteSiblings(el, allElements) : [el];
  352 + const showOrdinal = siblings.length > 1;
  353 + return buildElementEditorDisplayName(group, paletteLabel, slug, showOrdinal);
  354 +}
  355 +
  356 +/**
  357 + * @deprecated Use {@link elementEditorDisplayName}
  358 + */
  359 +export function elementPaletteOriginDisplayLabel(el: LabelElement): string | null {
  360 + const parsed = parseElementPaletteContext(el);
  361 + if (!parsed) return null;
  362 + const { group, paletteLabel } = parsed;
  363 + if (group !== "Template" && group !== "Label") return null;
  364 + if (!PALETTE_LABELS_SHARED_TEMPLATE_AND_LABEL.has(paletteLabel)) return null;
  365 + return `${paletteLabel} (${elementLibraryCategoryPanelTitle(group)})`;
  366 +}
  367 +
193 /** 368 /**
194 * 旧模板无「面板英文名」时的兜底(同 type 多入口时可能不准,新模板以点击面板为准)。 369 * 旧模板无「面板英文名」时的兜底(同 type 多入口时可能不准,新模板以点击面板为准)。
195 */ 370 */
@@ -370,15 +545,57 @@ export const PRESET_LABEL_SIZES: { name: string; width: number; height: number; @@ -370,15 +545,57 @@ export const PRESET_LABEL_SIZES: { name: string; width: number; height: number;
370 /** 标签编辑器画布字体:与左侧 Elements 列表、整站 UI(fonts.css --font-sans)一致 */ 545 /** 标签编辑器画布字体:与左侧 Elements 列表、整站 UI(fonts.css --font-sans)一致 */
371 export const LABEL_EDITOR_FONT_FAMILY = 'FreightSans Bold'; 546 export const LABEL_EDITOR_FONT_FAMILY = 'FreightSans Bold';
372 547
373 -/** 画布渲染时解析元素 fontFamily;历史模板中 Arial 视为 UI 字体 */ 548 +/** 属性面板可选字体(均已打包至 src/assets/fonts,见 label-editor-fonts.css) */
  549 +export const LABEL_EDITOR_FONT_OPTIONS: ReadonlyArray<{ value: string; label: string }> = [
  550 + { value: 'FreightSans Bold', label: 'FreightSans Bold' },
  551 + { value: 'Roboto', label: 'Roboto' },
  552 + { value: 'Open Sans', label: 'Open Sans' },
  553 + { value: 'Lato', label: 'Lato' },
  554 + { value: 'Tinos', label: 'Tinos' },
  555 + { value: 'Roboto Mono', label: 'Roboto Mono' },
  556 +];
  557 +
  558 +export const LABEL_EDITOR_BUNDLED_FONT_FAMILIES = new Set(
  559 + LABEL_EDITOR_FONT_OPTIONS.map((item) => item.value),
  560 +);
  561 +
  562 +/** 历史模板 / 系统字体名 → 已打包字体的映射 */
  563 +const LABEL_EDITOR_LEGACY_FONT_ALIASES: Record<string, string> = {
  564 + arial: 'Roboto',
  565 + 'arial, sans-serif': 'Roboto',
  566 + helvetica: 'Roboto',
  567 + 'helvetica, sans-serif': 'Roboto',
  568 + 'times new roman': 'Tinos',
  569 + 'courier new': 'Roboto Mono',
  570 + verdana: 'Open Sans',
  571 + georgia: 'Tinos',
  572 +};
  573 +
  574 +/** 将 config 中的 fontFamily 规范为已打包字体名;无法识别时返回 null */
  575 +export function normalizeLabelEditorFontFamily(raw: unknown): string | null {
  576 + const text = String(raw ?? '').trim();
  577 + if (!text) return null;
  578 + const alias = LABEL_EDITOR_LEGACY_FONT_ALIASES[text.toLowerCase()];
  579 + if (alias) return alias;
  580 + if (LABEL_EDITOR_BUNDLED_FONT_FAMILIES.has(text)) return text;
  581 + return null;
  582 +}
  583 +
  584 +/** 属性面板 Font 下拉当前值 */
  585 +export function readLabelEditorFontFamilyChoice(
  586 + cfg: Record<string, unknown> | null | undefined,
  587 +): string {
  588 + return (
  589 + normalizeLabelEditorFontFamily(cfg?.fontFamily ?? cfg?.FontFamily)
  590 + ?? LABEL_EDITOR_FONT_FAMILY
  591 + );
  592 +}
  593 +
  594 +/** 画布渲染时解析元素 fontFamily */
374 export function resolveLabelEditorElementFontFamily( 595 export function resolveLabelEditorElementFontFamily(
375 cfg: Record<string, unknown> | null | undefined, 596 cfg: Record<string, unknown> | null | undefined,
376 ): string { 597 ): string {
377 - const raw = String(cfg?.fontFamily ?? cfg?.FontFamily ?? '').trim();  
378 - if (!raw || /^arial(\s*,?\s*sans-serif)?$/i.test(raw)) {  
379 - return LABEL_EDITOR_FONT_FAMILY;  
380 - }  
381 - return raw; 598 + return readLabelEditorFontFamilyChoice(cfg);
382 } 599 }
383 600
384 /** 按 type 创建默认元素(带默认 config) */ 601 /** 按 type 创建默认元素(带默认 config) */
@@ -388,7 +605,7 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl @@ -388,7 +605,7 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl
388 const defaults: Record<ElementType, { width: number; height: number; config: Record<string, unknown> }> = { 605 const defaults: Record<ElementType, { width: number; height: number; config: Record<string, unknown> }> = {
389 TEXT_STATIC: { width: 120, height: 24, config: { text: 'Text', fontFamily: editorFont, fontSize: 14, fontWeight: 'normal', textAlign: 'left' } }, 606 TEXT_STATIC: { width: 120, height: 24, config: { text: 'Text', fontFamily: editorFont, fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
390 TEXT_PRODUCT: { width: 120, height: 24, config: { text: 'Product name', fontFamily: editorFont, fontSize: 14, fontWeight: 'normal', textAlign: 'left' } }, 607 TEXT_PRODUCT: { width: 120, height: 24, config: { text: 'Product name', fontFamily: editorFont, fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
391 - TEXT_PRICE: { width: 80, height: 24, config: { text: '0.00', decimal: 2, fontFamily: editorFont, fontSize: 14, fontWeight: 'bold', textAlign: 'right' } }, 608 + TEXT_PRICE: { width: 80, height: 24, config: { text: '0.00', decimal: 2, fontFamily: editorFont, fontSize: 14, fontWeight: 'bold', textAlign: 'right', verticalAlign: 'center' } },
392 BARCODE: { 609 BARCODE: {
393 width: 160, 610 width: 160,
394 height: 48, 611 height: 48,
@@ -414,24 +631,38 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl @@ -414,24 +631,38 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl
414 height: 24, 631 height: 24,
415 config: { format: 'Days', durationValue: 3, fontFamily: editorFont, fontSize: 14, textAlign: 'left' }, 632 config: { format: 'Days', durationValue: 3, fontFamily: editorFont, fontSize: 14, textAlign: 'left' },
416 }, 633 },
417 - WEIGHT: { width: 80, height: 24, config: { unit: 'g', value: 500, fontFamily: editorFont, fontSize: 14, textAlign: 'left' } }, 634 + WEIGHT: {
  635 + width: 80,
  636 + height: 24,
  637 + config: {
  638 + unit: 'g',
  639 + value: 500,
  640 + weightInputMode: 'net',
  641 + fontFamily: editorFont,
  642 + fontSize: 14,
  643 + textAlign: 'left',
  644 + },
  645 + },
418 WEIGHT_PRICE: { width: 100, height: 24, config: { unitPrice: 10, weight: 0.5, currency: '$', fontFamily: editorFont, fontSize: 14, textAlign: 'left' } }, 646 WEIGHT_PRICE: { width: 100, height: 24, config: { unitPrice: 10, weight: 0.5, currency: '$', fontFamily: editorFont, fontSize: 14, textAlign: 'left' } },
419 BLANK: { width: 80, height: 48, config: { fontFamily: editorFont } }, 647 BLANK: { width: 80, height: 48, config: { fontFamily: editorFont } },
420 NUTRITION: { 648 NUTRITION: {
421 - width: 200,  
422 - height: 120, 649 + width: 220,
  650 + height: 280,
423 config: { 651 config: {
424 fontFamily: editorFont, 652 fontFamily: editorFont,
425 nutritionTitleFontSize: 16, 653 nutritionTitleFontSize: 16,
  654 + nutritionFooterNote: DEFAULT_NUTRITION_FOOTER_NOTE,
426 servingsPerContainer: '', 655 servingsPerContainer: '',
427 servingSize: '', 656 servingSize: '',
428 calories: '', 657 calories: '',
429 - layout: 'standard',  
430 - fixedNutrients: NUTRITION_FIXED_ITEMS.map((item) => ({ 658 + layout: 'standard-v2',
  659 + fixedNutrients: NUTRITION_FACTS_LAYOUT_ROWS.map((item) => ({
431 key: item.key, 660 key: item.key,
432 label: item.label, 661 label: item.label,
433 value: '', 662 value: '',
434 unit: item.defaultUnit ?? '', 663 unit: item.defaultUnit ?? '',
  664 + dailyValuePercent: '',
  665 + lessThan: false,
435 })), 666 })),
436 extraNutrients: [], 667 extraNutrients: [],
437 }, 668 },
@@ -902,24 +1133,27 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean { @@ -902,24 +1133,27 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean {
902 return vst === "FIXED" || vst === "PRINT_INPUT"; 1133 return vst === "FIXED" || vst === "PRINT_INPUT";
903 } 1134 }
904 1135
905 -/** 录入表表头:优先 elementName,其次 inputKey,再推导 */  
906 -export function dataEntryColumnLabel(el: LabelElement): string {  
907 - const type = canonicalElementType(el.type);  
908 - const name = (el.elementName ?? "").trim();  
909 - if (name) return name;  
910 - const ik = elementInputKey(el);  
911 - if (ik) return ik; 1136 +/** 录入表表头:与属性面板 Element name 展示一致(内部仍用 elementId / elementName slug 存值) */
  1137 +export function dataEntryColumnLabel(el: LabelElement, allElements?: LabelElement[]): string {
  1138 + return elementEditorDisplayName(el, allElements);
  1139 +}
  1140 +
  1141 +/** 编辑器:锁定控件位置(存于 config,随模板 JSON 持久化) */
  1142 +export function readElementPositionLocked(el: LabelElement): boolean {
912 const cfg = (el.config ?? {}) as Record<string, unknown>; 1143 const cfg = (el.config ?? {}) as Record<string, unknown>;
913 - if (type === "TEXT_PRODUCT") {  
914 - const t = typeof cfg.text === "string" ? cfg.text.trim() : "";  
915 - return t || "Product name";  
916 - }  
917 - if (type === "TEXT_PRICE") {  
918 - const t = typeof cfg.text === "string" ? cfg.text.trim() : "";  
919 - return t || "Price";  
920 - }  
921 - if (type === "IMAGE") return "Image";  
922 - return printInputFieldLabel(el); 1144 + return cfg.positionLocked === true || cfg.PositionLocked === true;
  1145 +}
  1146 +
  1147 +export function patchElementPositionLocked(
  1148 + el: LabelElement,
  1149 + locked: boolean,
  1150 +): Pick<LabelElement, 'config'> {
  1151 + return {
  1152 + config: {
  1153 + ...(el.config ?? {}),
  1154 + positionLocked: locked,
  1155 + },
  1156 + };
923 } 1157 }
924 1158
925 /** 与详情接口 elements 顺序一致(orderNum → zIndex → 原序) */ 1159 /** 与详情接口 elements 顺序一致(orderNum → zIndex → 原序) */
@@ -1082,10 +1316,6 @@ const PERSISTED_TYPE_BY_GROUP_AND_LABEL: Record&lt;string, ElementType&gt; = { @@ -1082,10 +1316,6 @@ const PERSISTED_TYPE_BY_GROUP_AND_LABEL: Record&lt;string, ElementType&gt; = {
1082 "print|multiple options": "TEXT_STATIC", 1316 "print|multiple options": "TEXT_STATIC",
1083 }; 1317 };
1084 1318
1085 -function normalizePaletteLabelForKey(raw: string): string {  
1086 - return String(raw ?? "").trim().toLowerCase().replace(/\s+/g, " ");  
1087 -}  
1088 -  
1089 export function canonicalElementType(raw: ElementTypeValue): ElementType { 1319 export function canonicalElementType(raw: ElementTypeValue): ElementType {
1090 const type = String(raw ?? "").trim(); 1320 const type = String(raw ?? "").trim();
1091 if (ELEMENT_TYPE_SET.has(type as ElementType)) return type as ElementType; 1321 if (ELEMENT_TYPE_SET.has(type as ElementType)) return type as ElementType;
美国版/Food Labeling Management Platform/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 +/** Tailwind classes for &lt;img&gt; inside a fixed box */
  12 +export function imageScaleModeImgClassName(mode: ImageScaleMode): string {
  13 + switch (mode) {
  14 + case 'cover':
  15 + return 'h-full w-full object-cover';
  16 + case 'fill':
  17 + return 'h-full w-full object-fill';
  18 + default:
  19 + return 'max-h-full max-w-full object-contain';
  20 + }
  21 +}
  22 +
  23 +export function computeImageDrawRect(
  24 + boxW: number,
  25 + boxH: number,
  26 + sourceW: number,
  27 + sourceH: number,
  28 + scaleMode: string,
  29 +): { dx: number; dy: number; dw: number; dh: number } {
  30 + const mode = String(scaleMode ?? 'contain').trim().toLowerCase();
  31 + if (sourceW <= 0 || sourceH <= 0 || mode === 'fill') {
  32 + return { dx: 0, dy: 0, dw: boxW, dh: boxH };
  33 + }
  34 + const ratio =
  35 + mode === 'cover'
  36 + ? Math.max(boxW / sourceW, boxH / sourceH)
  37 + : Math.min(boxW / sourceW, boxH / sourceH);
  38 + const dw = Math.max(1, Math.round(sourceW * ratio));
  39 + const dh = Math.max(1, Math.round(sourceH * ratio));
  40 + return {
  41 + dx: Math.round((boxW - dw) / 2),
  42 + dy: Math.round((boxH - dh) / 2),
  43 + dw,
  44 + dh,
  45 + };
  46 +}
美国版/Food Labeling Management Platform/src/utils/invertColorsConfig.ts
@@ -16,6 +16,11 @@ export const INVERT_COLORS_BG = &quot;#000000&quot;; @@ -16,6 +16,11 @@ export const INVERT_COLORS_BG = &quot;#000000&quot;;
16 export const INVERT_COLORS_FG = "#ffffff"; 16 export const INVERT_COLORS_FG = "#ffffff";
17 17
18 export function isTextLikeElementForInvertColors(type: string): boolean { 18 export function isTextLikeElementForInvertColors(type: string): boolean {
  19 + return isTextLikeElementForLayout(type);
  20 +}
  21 +
  22 +/** 支持水平/垂直对齐与 invert 的文本类控件 */
  23 +export function isTextLikeElementForLayout(type: string): boolean {
19 const t = String(type || "").toUpperCase(); 24 const t = String(type || "").toUpperCase();
20 return ( 25 return (
21 t.startsWith("TEXT_") || 26 t.startsWith("TEXT_") ||
美国版/Food Labeling Management Platform/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 verticalAlignToFlexJustify(
  15 + align: TextVerticalAlign,
  16 +): 'flex-start' | 'center' | 'flex-end' {
  17 + if (align === 'center') return 'center';
  18 + if (align === 'bottom') return 'flex-end';
  19 + return 'flex-start';
  20 +}
  21 +
  22 +export function textAlignToFlexAlign(
  23 + align: string | undefined,
  24 +): 'flex-start' | 'center' | 'flex-end' {
  25 + const v = String(align ?? 'left').toLowerCase();
  26 + if (v === 'center') return 'center';
  27 + if (v === 'right') return 'flex-end';
  28 + return 'flex-start';
  29 +}
  30 +
  31 +/** 画布/位图:文本块在元素框内的顶部 Y 偏移(不含 baseline) */
  32 +export function computeVerticalTextBlockOffset(
  33 + innerHeight: number,
  34 + blockHeight: number,
  35 + verticalAlign: TextVerticalAlign,
  36 +): number {
  37 + const extra = Math.max(0, innerHeight - blockHeight);
  38 + if (verticalAlign === 'center') return Math.floor(extra / 2);
  39 + if (verticalAlign === 'bottom') return extra;
  40 + return 0;
  41 +}
  42 +
  43 +export function readFontWeight(
  44 + config: Record<string, unknown> | undefined | null,
  45 +): 'normal' | 'bold' {
  46 + const raw = config?.fontWeight ?? config?.FontWeight;
  47 + const v = String(raw ?? 'normal').trim().toLowerCase();
  48 + return v === 'bold' || v === '700' || v === 'bolder' ? 'bold' : 'normal';
  49 +}
  50 +
  51 +export function readFontStyle(
  52 + config: Record<string, unknown> | undefined | null,
  53 +): 'normal' | 'italic' {
  54 + const raw = config?.fontStyle ?? config?.FontStyle;
  55 + const v = String(raw ?? 'normal').trim().toLowerCase();
  56 + return v === 'italic' || v === 'oblique' ? 'italic' : 'normal';
  57 +}
  58 +
  59 +export function readTextDecoration(
  60 + config: Record<string, unknown> | undefined | null,
  61 +): 'none' | 'underline' {
  62 + const raw = config?.textDecoration ?? config?.TextDecoration;
  63 + const v = String(raw ?? 'none').trim().toLowerCase();
  64 + return v.includes('underline') ? 'underline' : 'none';
  65 +}
  66 +
  67 +/** 元素级 border(兼容 Border / BorderType) */
  68 +export function readElementBorder(
  69 + el: { border?: string | null; Border?: string | null; BorderType?: string | null; borderType?: string | null },
  70 +): string {
  71 + const raw = el.border ?? el.Border ?? el.BorderType ?? el.borderType ?? 'none';
  72 + return String(raw).trim().toLowerCase() || 'none';
  73 +}
  74 +
  75 +/** 读取元素 rotation(兼容大小写 / 旧数据) */
  76 +export function readElementRotation(
  77 + el: { rotation?: string | null },
  78 +): 'horizontal' | 'vertical' {
  79 + const v = String(el.rotation ?? 'horizontal').trim().toLowerCase();
  80 + return v === 'vertical' ? 'vertical' : 'horizontal';
  81 +}
  82 +
  83 +/** 切换 horizontal / vertical 时交换宽高并保持中心点不变(与参考图 2 一致) */
  84 +export function patchElementRotationWithLayout(
  85 + el: { x: number; y: number; width: number; height: number; rotation?: string | null },
  86 + nextRotation: 'horizontal' | 'vertical',
  87 +): {
  88 + rotation: 'horizontal' | 'vertical';
  89 + x: number;
  90 + y: number;
  91 + width: number;
  92 + height: number;
  93 +} {
  94 + const prev = readElementRotation(el);
  95 + if (prev === nextRotation) {
  96 + // vertical 但宽>高:选框与文字方向不一致,强制纠正
  97 + if (nextRotation === 'vertical' && el.width > el.height) {
  98 + const w = Math.max(1, el.width);
  99 + const h = Math.max(1, el.height);
  100 + const cx = el.x + w / 2;
  101 + const cy = el.y + h / 2;
  102 + const newW = h;
  103 + const newH = w;
  104 + return {
  105 + rotation: nextRotation,
  106 + width: newW,
  107 + height: newH,
  108 + x: Math.round(cx - newW / 2),
  109 + y: Math.round(cy - newH / 2),
  110 + };
  111 + }
  112 + return {
  113 + rotation: nextRotation,
  114 + x: el.x,
  115 + y: el.y,
  116 + width: el.width,
  117 + height: el.height,
  118 + };
  119 + }
  120 + const w = Math.max(1, el.width);
  121 + const h = Math.max(1, el.height);
  122 + const cx = el.x + w / 2;
  123 + const cy = el.y + h / 2;
  124 + const newW = h;
  125 + const newH = w;
  126 + return {
  127 + rotation: nextRotation,
  128 + width: newW,
  129 + height: newH,
  130 + x: Math.round(cx - newW / 2),
  131 + y: Math.round(cy - newH / 2),
  132 + };
  133 +}
  134 +
  135 +/** 竖排且宽>高时纠正选框(落库 / 渲染用) */
  136 +export function canonicalElementGeometry<
  137 + T extends { rotation?: string | null; width: number; height: number; x: number; y: number },
  138 +>(el: T): T {
  139 + return normalizeElementRotationBox(el);
  140 +}
  141 +
  142 +/** 旧模板 vertical 但宽>高时自动纠正选框 */
  143 +export function normalizeElementRotationBox<
  144 + T extends { rotation?: string | null; width: number; height: number; x: number; y: number },
  145 +>(el: T): T {
  146 + if (readElementRotation(el) !== 'vertical') return el;
  147 + if (el.width <= el.height) return el;
  148 + return {
  149 + ...el,
  150 + rotation: 'vertical',
  151 + ...patchElementRotationWithLayout({ ...el, rotation: 'horizontal' }, 'vertical'),
  152 + };
  153 +}
美国版/Food Labeling Management Platform/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 +/** Label display: numeric value + template unit (e.g. 500 + g → 500g). */
  11 +export function formatWeightDisplay(rawValue: string, unit: string): string {
  12 + const raw = String(rawValue ?? '').trim()
  13 + const u = String(unit ?? '').trim()
  14 + if (!raw) return ''
  15 + if (u && !raw.endsWith(u)) return `${raw}${u}`
  16 + return raw
  17 +}
  18 +
  19 +export function weightInputPlaceholder(mode: WeightInputMode): string {
  20 + return mode === 'tare' ? 'Enter weight or read from scale' : 'Net weight'
  21 +}
  22 +
  23 +export const WEIGHT_INPUT_MODE_OPTIONS: Array<{ value: WeightInputMode; label: string }> = [
  24 + { value: 'net', label: 'Net weight' },
  25 + { value: 'tare', label: 'Tare weight' },
  26 +]