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 3 import { initOfflineSqlite } from "./utils/sqliteSync";
4 4 import { syncNowAndRefreshCaches } from "./utils/offlineSyncManager";
5 5 import { isLoggedIn } from "./utils/authSession";
  6 +import { preloadAllLabelEditorFonts } from "./utils/labelEditorFonts";
6 7  
7 8 let networkSyncInFlight = false;
8 9  
9 10 onLaunch(() => {
10 11 void initOfflineSqlite();
  12 + void preloadAllLabelEditorFonts();
11 13 uni.onNetworkStatusChange((res) => {
12 14 if (!res.isConnected || !isLoggedIn() || networkSyncInFlight) return;
13 15 networkSyncInFlight = true;
... ...
美国版/Food Labeling Management App UniApp/src/pages/labels/preview.vue
... ... @@ -63,7 +63,41 @@
63 63 </text>
64 64 <view v-for="el in printFreeFieldList" :key="'free-' + el.id" class="print-option-block">
65 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 101 <input
68 102 class="free-field-input"
69 103 type="text"
... ... @@ -268,6 +302,12 @@ import {
268 302 validatePrintInputOptionsBeforePrint,
269 303 } from '../../utils/labelPreview/printInputOptions'
270 304 import {
  305 + readWeightInputMode,
  306 + weightInputPlaceholder,
  307 + type SmartScaleReadKind,
  308 +} from '../../utils/weightElement'
  309 +import { readWeightFromSmartScale } from '../../utils/smartScaleService'
  310 +import {
271 311 buildLabelPrintJobPayload,
272 312 setLastLabelPrintJobPayload,
273 313 } from '../../utils/labelPreview/buildLabelPrintPayload'
... ... @@ -314,6 +354,7 @@ import {
314 354 import {
315 355 normalizeTemplateForNativeFastJob,
316 356 templateHasUnsupportedNativeFastElements,
  357 + templateRequiresCanvasStyleFidelity,
317 358 } from '../../utils/print/nativeTemplateElementSupport'
318 359 import {
319 360 ensureTemplateHeightCoversElements,
... ... @@ -834,6 +875,8 @@ const printOptionSelections = ref&lt;Record&lt;string, string[]&gt;&gt;({})
834 875 const dictLabelsByElementId = ref<Record<string, string>>({})
835 876 const dictValuesByElementId = ref<Record<string, string[]>>({})
836 877 const printFreeFieldValues = ref<Record<string, string>>({})
  878 +const weightScaleReadingId = ref('')
  879 +const weightScaleReadingKind = ref<SmartScaleReadKind | ''>('')
837 880 const pickerDialogVisible = ref(false)
838 881 const pickerMode = ref<'date' | 'time' | 'datetime'>('date')
839 882 const pickerSelection = ref<number[]>([0, 0, 0, 0, 0])
... ... @@ -913,15 +956,40 @@ function freeFieldNameLabel(el: SystemTemplateElementBase): string {
913 956 return `${n}:`
914 957 }
915 958  
  959 +function isWeightPrintField(el: SystemTemplateElementBase): boolean {
  960 + return String(el.type || '').toUpperCase() === 'WEIGHT'
  961 +}
  962 +
916 963 function freeFieldPlaceholder(el: SystemTemplateElementBase): string {
917 964 const c = el.config || {}
918 965 const type = String(el.type || '').toUpperCase()
  966 + if (type === 'WEIGHT') {
  967 + return weightInputPlaceholder(readWeightInputMode(c))
  968 + }
919 969 if (type === 'DATE' || type === 'TIME') {
920 970 return String(c.format ?? c.Format ?? '')
921 971 }
922 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 993 function freeFieldDateFormat(el: SystemTemplateElementBase): string {
926 994 const c = el.config || {}
927 995 return String(c.format ?? c.Format ?? '').trim()
... ... @@ -1644,6 +1712,7 @@ const handlePrint = async () =&gt; {
1644 1712 canPrintCurrentLabelViaNativeFastJob()
1645 1713 && isTemplateWithinNativeFastPrintBounds(tmpl)
1646 1714 && !templateHasUnsupportedNativeFastElements(tmplForNativeJob)
  1715 + && !templateRequiresCanvasStyleFidelity(tmpl)
1647 1716  
1648 1717 if (ENABLE_PRINT_PREFLIGHT_MODAL) {
1649 1718 // 一体机无 console:打印前弹设备链路信息(仅调试模式开启)
... ... @@ -1863,7 +1932,8 @@ const handlePrint = async () =&gt; {
1863 1932 const shouldUseDirectTemplate =
1864 1933 driver.protocol === 'tsc' &&
1865 1934 templateHasQrDataForCommandPrint(tmpl) &&
1866   - !templateHasUnsupportedElementsForCommandPrint(tmpl)
  1935 + !templateHasUnsupportedElementsForCommandPrint(tmpl) &&
  1936 + !templateRequiresCanvasStyleFidelity(tmpl)
1867 1937 if (shouldUseDirectTemplate) {
1868 1938 const directJobMs = 240000
1869 1939 if (globalWatchdog) {
... ... @@ -2485,6 +2555,30 @@ const handlePrint = async () =&gt; {
2485 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 2582 .picker-input {
2489 2583 display: flex;
2490 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 51 'FontSize',
52 52 'textAlign',
53 53 'TextAlign',
  54 + 'verticalAlign',
  55 + 'VerticalAlign',
54 56 'fontFamily',
  57 + 'FontFamily',
55 58 'fontWeight',
  59 + 'FontWeight',
  60 + 'fontStyle',
  61 + 'FontStyle',
  62 + 'textDecoration',
  63 + 'TextDecoration',
56 64 'color',
57 65 'Color',
58 66 'src',
... ... @@ -81,6 +89,8 @@ function mergeFlatElementFieldsIntoConfig(
81 89 'SelectedOptionValues',
82 90 'errorLevel',
83 91 'scaleMode',
  92 + 'weightInputMode',
  93 + 'WeightInputMode',
84 94 'showText',
85 95 'placeholder',
86 96 'Placeholder',
... ... @@ -550,7 +560,7 @@ export function normalizeLabelTemplateFromPreviewApi(payload: unknown): SystemLa
550 560 width: Number(e.width ?? e.Width ?? 0),
551 561 height: Number(e.height ?? e.Height ?? 0),
552 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 564 config: cfg as Record<string, any>,
555 565 zIndex: Number(e.zIndex ?? e.ZIndex ?? 0),
556 566 orderNum: Number(e.orderNum ?? e.OrderNum ?? index),
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/nutritionDefaultsMerge.ts
1 1 /**
2 2 * 将管理端保存的营养成分默认值 JSON 合并进 NUTRITION 元素 config(与 Web nutritionManualEntry 字段一致)。
  3 + * `<` 前缀由模板 config 决定,录入 JSON 不包含 LessThan 字段。
3 4 */
  5 +import { 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 16 export function applyNutritionDefaultJsonToConfig(
5 17 baseCfg: Record<string, unknown>,
6 18 jsonStr: string,
7 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 23 try {
12   - manual = JSON.parse(t) as Record<string, string>;
  24 + manual = JSON.parse(t) as Record<string, string>
13 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 4 SystemTemplateElementBase,
5 5 } from '../print/types/printer'
6 6 import { isUniAppDateTimeOffsetField } from './printInputOffset'
  7 +import { formatWeightDisplay } from '../weightElement'
7 8  
8 9 /**
9 10 * 打印/预览 printInputJson 的 key:与接口文档一致优先 inputKey,否则 elementName,最后兜底元素 id。
... ... @@ -123,7 +124,12 @@ export function mergePrintInputFreeFields(
123 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 133 const next = { ...cfg, text: display } as Record<string, any>
128 134 if (type === 'WEIGHT') {
129 135 next.value = raw
... ...
美国版/Food Labeling Management App UniApp/src/utils/labelPreview/renderLabelPreviewCanvas.ts
... ... @@ -5,22 +5,22 @@ import { resolveElementDateTimeDisplay, isLikelyResolvedDateTimeLiteral } from &#39;
5 5 import { getLoggedInEmployeeDisplayName, isEmployeeTemplateElement } from './employeeElement'
6 6 import QRCode from 'qrcode'
7 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 25 /** 与 Web LabelCanvas.unitToPx 一致:cm 用 37.8px/inch,保证与后台模板坐标系一致 */
26 26 const PX_PER_CM = 37.8
... ... @@ -64,6 +64,74 @@ function readFillColor(config: Record&lt;string, any&gt;): string {
64 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 136 function maxCharsPerLine(innerWidthPx: number, fontSize: number): number {
69 137 if (innerWidthPx <= 4) return 8
... ... @@ -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 605 function strokeTemplatePaperBorder (
... ... @@ -394,16 +610,38 @@ function strokeTemplatePaperBorder (
394 610 ) {
395 611 const border = String(template.border || '').toLowerCase()
396 612 if (border !== 'line' && border !== 'dotted') return
397   - ctx.setStrokeStyle('#333333')
398   - ctx.setLineWidth(1)
  613 + ctx.setStrokeStyle('#374151')
  614 + ctx.setLineWidth(2)
399 615 const w = Math.max(0, cw - 1)
400 616 const h = Math.max(0, ch - 1)
401 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 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 642 ;(ctx as any).setLineDash([], 0)
405 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 666 const sorted = sortElementsForPreview(template.elements || [])
429 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 672 const ctx = uni.createCanvasContext(canvasId, componentInstance)
433 673 ctx.setFillStyle('#ffffff')
434 674 ctx.scale(scale, scale)
... ... @@ -457,108 +697,51 @@ function runLabelPreviewCanvasDraw(
457 697 const h = Math.max(0, Number(el.height) || 0)
458 698  
459 699 const next = () => drawRest(index + 1)
  700 + const finishElement = () => {
  701 + strokeElementBorder(ctx, x, y, w, h, readElementBorder(el))
  702 + next()
  703 + }
460 704  
461 705 if (type === 'IMAGE' || type === 'LOGO') {
462 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 710 if (src) {
464 711 uni.getImageInfo({
465 712 src,
466 713 success: (info) => {
467 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 723 } catch (_) {
470 724 ctx.setStrokeStyle('#cccccc')
471 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 730 fail: () => {
477 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 736 return
483 737 }
484   - next()
  738 + finishElement()
485 739 return
486 740 }
487 741  
488 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 745 return
563 746 }
564 747  
... ... @@ -581,16 +764,18 @@ function runLabelPreviewCanvasDraw(
581 764 }
582 765  
583 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 770 const showText = String(config.showText ?? config.ShowText ?? 'true').toLowerCase() !== 'false'
586 771 drawBarcodeLikePreview(ctx, x, y, w || 140, h || 56, d, { orientation, showText })
587   - next()
  772 + finishElement()
588 773 return
589 774 }
590 775  
591 776 if (type === 'QRCODE' && d && !storedValueLooksLikeImagePath(d)) {
592 777 drawQrCodePreview(ctx, x, y, w || 96, h || 96, d, cfgStr(config, ['errorLevel', 'ErrorLevel'], 'M'))
593   - next()
  778 + finishElement()
594 779 return
595 780 }
596 781  
... ... @@ -608,11 +793,11 @@ function runLabelPreviewCanvasDraw(
608 793 } catch (_) {
609 794 drawQrBarcodePlaceholder()
610 795 }
611   - next()
  796 + finishElement()
612 797 },
613 798 fail: () => {
614 799 drawQrBarcodePlaceholder()
615   - next()
  800 + finishElement()
616 801 },
617 802 })
618 803 return
... ... @@ -620,73 +805,81 @@ function runLabelPreviewCanvasDraw(
620 805 }
621 806  
622 807 drawQrBarcodePlaceholder()
623   - next()
  808 + finishElement()
624 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 812 const text = previewTextForElement(el)
645 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 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 38 import { storedValueLooksLikeImagePath } from '../../resolveMediaUrl'
39 39 import { printRunDiag } from '../printRunDiagnostics'
40 40 import { adaptSystemLabelTemplate } from '../systemTemplateAdapter'
  41 +import { templateRequiresCanvasStyleFidelity } from '../nativeTemplateElementSupport'
41 42 import { hydrateSystemTemplateImagesForPrint } from '../hydrateTemplateImagesForPrint'
42 43 import { TEST_PRINT_SYSTEM_TEMPLATE, TEST_PRINT_TEMPLATE_DATA } from '../templates/testPrintTemplate'
43 44 import { describePrinterCandidate, getPrinterDriverByKey, resolvePrinterDriver } from './driverRegistry'
... ... @@ -1038,6 +1039,7 @@ export async function printSystemTemplateForCurrentPrinter (
1038 1039 !!canvasRaster
1039 1040 && templateHasQrDataForCommandPrint(template)
1040 1041 && !templateHasUnsupportedElementsForCommandPrint(template)
  1042 + && !templateRequiresCanvasStyleFidelity(template)
1041 1043  
1042 1044 if (canvasRaster && !bypassCanvasRasterForQr) {
1043 1045 if (onProgress) onProgress(1)
... ...
美国版/Food Labeling Management App UniApp/src/utils/print/nativeBitmapPatch.ts
... ... @@ -4,6 +4,13 @@ import type {
4 4 SystemTemplateTextAlign,
5 5 } from './types/printer'
6 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 15 declare const plus: any
9 16  
... ... @@ -188,6 +195,11 @@ export function shouldRasterizeTextElement (
188 195 */
189 196 if (normalizedType === 'TEXT_PRICE') return true
190 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 203 return /[^\x20-\x7E]/.test(normalizedText)
192 204 }
193 205  
... ... @@ -224,9 +236,25 @@ export function createTextBitmapPatch (params: {
224 236 paint.setSubpixelText(true)
225 237 const fontSizeDots = Math.max(14, pxToDots(Number(config.fontSize || 14), dpi))
226 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 259 const maxTextWidth = Math.max(8, contentWidth)
232 260 const lines = splitTextLines(text, paint, maxTextWidth)
... ... @@ -236,10 +264,13 @@ export function createTextBitmapPatch (params: {
236 264 Math.ceil(Math.abs(Number(fontMetrics.top)) + Math.abs(Number(fontMetrics.bottom)) + 2)
237 265 )
238 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 275 for (let i = 0; i < lines.length; i++) {
245 276 const line = lines[i]
... ... @@ -252,6 +283,15 @@ export function createTextBitmapPatch (params: {
252 283 }
253 284 const baseline = topOffset + i * lineHeight - Number(fontMetrics.top)
254 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 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 11 import { applyTemplateData } from './templateRenderer'
12 12 import { resolveElementDateTimeDisplay } from '../labelPreview/printInputOffset'
13 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 25 function isElementHandledByNativeFastPrinter (el: SystemTemplateElementBase): boolean {
16 26 const type = String(el.type || '').toUpperCase()
... ... @@ -188,3 +198,50 @@ export function templateHasUnsupportedNativeFastElements (template: SystemLabelT
188 198 }
189 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 shouldRasterizeTextElement,
7 7 } from './nativeBitmapPatch'
8 8 import { applyTemplateData } from './templateRenderer'
  9 +import { readElementBorder } from '../textElementLayout'
9 10 import type {
10 11 EscTemplateItem,
11 12 LabelTemplateData,
... ... @@ -352,7 +353,7 @@ function pushTemplatePaperBorderIfNeeded (
352 353 y: 0,
353 354 width: Math.max(1, pxToDots(templateWidthPx(template), dpi)),
354 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 362 element: SystemTemplateElementBase,
362 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 366 if (border !== 'line' && border !== 'dotted') return
368 367 items.push({
369 368 type: 'box',
... ... @@ -550,16 +549,6 @@ function buildTscTemplate (
550 549 if (bitmapPatch) items.push(bitmapPatch)
551 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 554 const maxBottomDots = items.reduce(
... ... @@ -650,7 +639,7 @@ function buildEscTemplate (
650 639 return
651 640 }
652 641  
653   - if (type === 'BLANK' && String(element.border || '').toLowerCase() === 'line') {
  642 + if (type === 'BLANK' && readElementBorder(element) === 'line') {
654 643 items.push({
655 644 type: 'rule',
656 645 width: clamp(element.width / 8, 8, 48),
... ...
美国版/Food Labeling Management App UniApp/src/utils/printFromPrintDataList.ts
... ... @@ -22,6 +22,7 @@ import {
22 22 import {
23 23 normalizeTemplateForNativeFastJob,
24 24 templateHasUnsupportedNativeFastElements,
  25 + templateRequiresCanvasStyleFidelity,
25 26 } from './print/nativeTemplateElementSupport'
26 27 import {
27 28 ensureTemplateHeightCoversElements,
... ... @@ -364,6 +365,7 @@ async function printReprintTemplateWithPreviewStrategy (
364 365 canPrintCurrentLabelViaNativeFastJob()
365 366 && isTemplateWithinNativeFastPrintBounds(tmplSized)
366 367 && !templateHasUnsupportedNativeFastElements(tmplForNative)
  368 + && !templateRequiresCanvasStyleFidelity(tmplSized)
367 369  
368 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 5 namespace FoodLabeling.Application.Contracts.Dtos.Label;
6 6  
7 7 /// <summary>
8   -/// 预览输出:与前端 LabelCanvas/LabelPreviewOnly 的 LabelTemplate 结构尽量一致
  8 +/// Preview payload aligned with frontend LabelCanvas / LabelPreviewOnly template shape.
9 9 /// </summary>
10 10 public class LabelTemplatePreviewDto
11 11 {
... ... @@ -36,7 +36,7 @@ public class LabelTemplatePreviewDto
36 36 [JsonPropertyName("showGrid")]
37 37 public bool ShowGrid { get; set; }
38 38  
39   - /// <summary>整标签外框:none / line / dotted</summary>
  39 + /// <summary>Label outer border: none / line / dotted</summary>
40 40 [JsonPropertyName("border")]
41 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 3 namespace FoodLabeling.Application.Contracts.Dtos.LabelTemplate;
4 4  
5 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 7 /// </summary>
8 8 public class LabelTemplateElementDto
9 9 {
... ... @@ -13,9 +13,6 @@ public class LabelTemplateElementDto
13 13 [JsonPropertyName("type")]
14 14 public string ElementType { get; set; } = string.Empty;
15 15  
16   - /// <summary>
17   - /// 元素附加类型(分组前缀 + 控件名),如 label_Duration
18   - /// </summary>
19 16 [JsonPropertyName("typeAdd")]
20 17 public string? TypeAdd { get; set; }
21 18  
... ... @@ -46,9 +43,6 @@ public class LabelTemplateElementDto
46 43 [JsonPropertyName("orderNum")]
47 44 public int OrderNum { get; set; }
48 45  
49   - /// <summary>
50   - /// 值来源:FIXED / AUTO_DB / PRINT_INPUT
51   - /// </summary>
52 46 [JsonPropertyName("valueSourceType")]
53 47 public string ValueSourceType { get; set; } = "FIXED";
54 48  
... ... @@ -64,10 +58,6 @@ public class LabelTemplateElementDto
64 58 [JsonPropertyName("isRequiredInput")]
65 59 public bool IsRequiredInput { get; set; }
66 60  
67   - /// <summary>
68   - /// 元素配置
69   - /// </summary>
70 61 [JsonPropertyName("config")]
71 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 2 using FoodLabeling.Application.Services.DbModels;
2 3 using SqlSugar;
3 4  
4 5 namespace FoodLabeling.Application.Helpers;
5 6  
6 7 /// <summary>
7   -/// 标签模板列表「Items」列:汇总模板内各控件展示名(按 <c>OrderNum</c> 排序)。
  8 +/// Builds template list "Items" column from element display names (ordered by <c>OrderNum</c>).
8 9 /// </summary>
9 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 63 public static async Task<Dictionary<string, TemplateItemsDisplay>> ResolveTemplateItemsMapAsync(
15 64 ISqlSugarClient db,
16 65 IReadOnlyList<string> templateIds)
... ... @@ -52,7 +101,7 @@ public static class LabelTemplateListItemsHelper
52 101 result[group.Key] = new TemplateItemsDisplay
53 102 {
54 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 116 return result;
68 117 }
69 118  
70   - /// <summary>
71   - /// 控件展示名:优先 <c>ElementName</c>,其次 <c>TypeAdd</c> 后缀,最后按 <c>ElementType</c> 映射。
72   - /// </summary>
73 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 146 if (!string.IsNullOrWhiteSpace(ta))
83 147 {
84 148 var idx = ta.IndexOf('_');
... ... @@ -93,6 +157,63 @@ public static class LabelTemplateListItemsHelper
93 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 217 private static string MapElementTypeToDisplayLabel(string? elementType)
97 218 {
98 219 var t = (elementType ?? string.Empty).Trim().ToUpperInvariant();
... ... @@ -117,9 +238,9 @@ public static class LabelTemplateListItemsHelper
117 238  
118 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 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 5 <meta charset="UTF-8" />
6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7 7 <title>Food Labeling Management Platform</title>
8   - <script type="module" crossorigin src="/assets/index-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 10 </head>
11 11  
12 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 236 if (visual.mode === "none") {
237 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 242 export function LabelCategoriesView() {
... ... @@ -1164,6 +1164,7 @@ function CreateLabelCategoryDialog({
1164 1164 ) : null}
1165 1165  
1166 1166 <CategoryButtonAppearancePreview
  1167 + variant="labelCategory"
1167 1168 apSel={apSel}
1168 1169 displayText={displayTextForPhoto}
1169 1170 buttonBgColor={buttonBgColor}
... ... @@ -1635,6 +1636,7 @@ function EditLabelCategoryDialog({
1635 1636 ) : null}
1636 1637  
1637 1638 <CategoryButtonAppearancePreview
  1639 + variant="labelCategory"
1638 1640 apSel={apSel}
1639 1641 displayText={displayTextForPhoto}
1640 1642 buttonBgColor={buttonBgColor}
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateDataEntryView.tsx
... ... @@ -47,6 +47,7 @@ import {
47 47 nutritionCompositeFieldKey,
48 48 type NutritionManualFieldSpec,
49 49 } from '../../lib/nutritionManualEntry';
  50 +import { NutritionManualFieldControl } from './NutritionManualFieldControl';
50 51 import type { ProductDto } from '../../types/product';
51 52 import type { LabelTypeDto } from '../../types/labelType';
52 53 import {
... ... @@ -548,7 +549,7 @@ export function LabelTemplateDataEntryView({
548 549 className="font-bold text-gray-900 min-w-[120px] whitespace-nowrap"
549 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 553 </TableHead>
553 554 ))}
554 555 <TableHead className="w-[72px] text-center font-bold text-gray-900"> </TableHead>
... ... @@ -591,21 +592,22 @@ export function LabelTemplateDataEntryView({
591 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 598 value={
596 599 row.fieldValues[
597 600 nutritionCompositeFieldKey(col.parent.id, col.spec.subKey)
598 601 ] ?? ''
599 602 }
600   - onChange={(e) =>
  603 + onChange={(next) =>
601 604 setFieldValue(
602 605 row.id,
603 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 613 </TableCell>
... ...
美国版/Food Labeling Management Platform/src/components/labels/LabelTemplateEditor/ElementsPanel.tsx
1 1 import React from "react";
2   -import { Info } from "lucide-react";
3 2 import type { ElementLibraryCategory, ElementType } from "../../../types/labelTemplate";
4 3 import {
5 4 elementLibraryCategoryPanelTitle,
... ... @@ -84,6 +83,7 @@ interface ElementsPanelProps {
84 83 libraryCategory: ElementLibraryCategory,
85 84 paletteItemLabel: string,
86 85 ) => void;
  86 + onOpenInstruction: () => void;
87 87 }
88 88  
89 89 /** 各分组:无底色,蓝色细实线边框 */
... ... @@ -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 100 * 左侧元素库。纵向滚动由父级(index 左侧列容器)的 overflow-y:auto 负责,
144 101 * 避免预编译 CSS 缺少 min-h-0 / flex-1 时内层滚动高度为 0 导致底部橙色区被裁切。
145 102 */
146   -export function ElementsPanel({ onAddElement }: ElementsPanelProps) {
  103 +export function ElementsPanel({ onAddElement, onOpenInstruction }: ElementsPanelProps) {
147 104 return (
148 105 <div className="border-r border-slate-200 bg-slate-50">
149 106 <div className="border-b border-[#93c5fd] bg-white px-2 py-2 text-sm font-semibold text-slate-800">
150 107 Elements
151 108 </div>
152   - <div className="p-2 space-y-3">
  109 + <div className="space-y-3 p-2">
153 110 {ELEMENT_CATEGORIES.map((cat) => (
154 111 <div key={cat.title} style={sectionSurfaceStyle()}>
155 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 143 </div>
187 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 155 </div>
191 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 4 import type {
5 5 LabelTemplate,
6 6 LabelElement,
7   - NutritionExtraItem,
8 7 PrintOrientation,
9 8 } from '../../../types/labelTemplate';
10 9 import {
11 10 canonicalElementType,
12 11 isPrintInputElement,
13 12 isCompanyAutoElement,
  13 + readElementPositionLocked,
  14 + patchElementPositionLocked,
14 15 resolveLabelEditorElementFontFamily,
  16 + parseElementPaletteContext,
  17 + inferPaletteEnglishLabel,
15 18 } from '../../../types/labelTemplate';
16 19 import {
17 20 INVERT_COLORS_BG,
... ... @@ -19,9 +22,10 @@ import {
19 22 readInvertColors,
20 23 } from '../../../utils/invertColorsConfig';
21 24 import { PRESET_LABEL_SIZES } from '../../../types/labelTemplate';
22   -import { NUTRITION_FIXED_ITEMS } from '../../../types/labelTemplate';
23 25 import { cn } from '../../ui/utils';
24 26 import { resolvePictureUrlForDisplay } from '../../../services/imageUploadService';
  27 +import { imageScaleModeImgClassName, readImageScaleMode } from '../../../utils/imageScaleMode';
  28 +import { formatWeightDisplay } from '../../../utils/weightElement';
25 29 import {
26 30 Select,
27 31 SelectContent,
... ... @@ -40,6 +44,20 @@ import {
40 44 previewRulerUnitLabel,
41 45 } from '@/utils/previewRulerUnits';
42 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 62 export type { PrintOrientation } from '../../../types/labelTemplate';
45 63  
... ... @@ -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 139 /** 横打预览 rotate(90deg) 时,屏幕拖拽增量映射到模板宽/高增量 */
122 140 export function elementResizeDeltaFromPointer(
... ... @@ -178,12 +196,12 @@ export function computeResizedElementBox(
178 196 let ny = elY;
179 197 if (handleId.includes('e')) nw = Math.max(20, w + dw);
180 198 if (handleId.includes('w')) {
181   - nw = Math.max(20, w - dw);
  199 + nw = Math.max(20, w + dw);
182 200 nx = elX + (w - nw);
183 201 }
184 202 if (handleId.includes('s')) nh = Math.max(12, h + dh);
185 203 if (handleId.includes('n')) {
186   - nh = Math.max(12, h - dh);
  204 + nh = Math.max(12, h + dh);
187 205 ny = elY + (h - nh);
188 206 }
189 207 return { x: nx, y: ny, width: nw, height: nh };
... ... @@ -470,6 +488,11 @@ export function mergeLabelElementLivePatch(
470 488 return { ...el, ...patch };
471 489 }
472 490  
  491 +/** 竖排且宽>高时纠正选框(仅用于落库/渲染,不在 live patch 合并时调用) */
  492 +function displayElementGeometry(el: LabelElement): LabelElement {
  493 + return canonicalElementGeometry(el);
  494 +}
  495 +
473 496 export function mergeTemplateElementsLivePatch(
474 497 elements: LabelElement[],
475 498 patch: LabelElementLivePatch | null | undefined,
... ... @@ -509,7 +532,7 @@ function formatSelectionLengthForPreviewRuler(
509 532 }
510 533  
511 534 /** 画布选中框旁尺寸读数(仅数值,与参考图一致) */
512   -function formatElementDimensionValue(
  535 +function formatElementDimensionLabelValue(
513 536 lengthPx: number,
514 537 basePaperPx: number,
515 538 paperSizeTemplate: number,
... ... @@ -523,11 +546,55 @@ function formatElementDimensionValue(
523 546 templateUnit,
524 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 598 const ELEMENT_RESIZE_HANDLES = [
532 599 { id: "nw", cx: 0, cy: 0, cursor: "nwse-resize" },
533 600 { id: "n", cx: 0.5, cy: 0, cursor: "ns-resize" },
... ... @@ -539,19 +606,47 @@ const ELEMENT_RESIZE_HANDLES = [
539 606 { id: "w", cx: 0, cy: 0.5, cursor: "ew-resize" },
540 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 650 function ElementSelectionFrame({
556 651 el,
557 652 templateUnit,
... ... @@ -563,6 +658,8 @@ function ElementSelectionFrame({
563 658 printOrientation = 'vertical',
564 659 interactive = false,
565 660 onResizePointerDown,
  661 + onDragPointerDown,
  662 + positionLocked = false,
566 663 }: {
567 664 el: LabelElement;
568 665 templateUnit: "cm" | "inch";
... ... @@ -574,19 +671,21 @@ function ElementSelectionFrame({
574 671 printOrientation?: PrintOrientation;
575 672 interactive?: boolean;
576 673 onResizePointerDown?: (e: React.PointerEvent, handleId: string) => void;
  674 + onDragPointerDown?: (e: React.PointerEvent) => void;
  675 + positionLocked?: boolean;
577 676 }) {
578 677 const x = el.x;
579 678 const y = el.y;
580 679 const w = Math.max(1, el.width);
581 680 const h = Math.max(1, el.height);
582   - const wValue = formatElementDimensionValue(
  681 + const wValue = formatElementDimensionLabelValue(
583 682 w,
584 683 paperWidthPx,
585 684 paperWidthTemplate,
586 685 templateUnit,
587 686 displayUnit,
588 687 );
589   - const hValue = formatElementDimensionValue(
  688 + const hValue = formatElementDimensionLabelValue(
590 689 h,
591 690 paperHeightPx,
592 691 paperHeightTemplate,
... ... @@ -594,20 +693,39 @@ function ElementSelectionFrame({
594 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 706 const isRotatedPrint = printOrientation === 'horizontal';
606 707 const displayWidthLabel = isRotatedPrint ? hValue : wValue;
607 708 const displayHeightLabel = isRotatedPrint ? wValue : hValue;
  709 + const resizeHandles = elementResizeHandlesForBox(w, h, el.rotation);
  710 + const dragHit = elementDragHitBounds(el);
608 711  
609 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 729 <div
612 730 className="pointer-events-none absolute left-0 top-0 z-[40]"
613 731 style={{ width: paperWidthPx, height: paperHeightPx }}
... ... @@ -624,43 +742,39 @@ function ElementSelectionFrame({
624 742 width={w}
625 743 height={h}
626 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 750 <text
636 751 x={x + w / 2}
637 752 y={widthTextY}
638 753 textAnchor="middle"
639   - fontSize={11}
  754 + fontSize={ELEMENT_DIMENSION_LABEL_FONT_SIZE}
640 755 fill="#111827"
  756 + fontWeight={600}
641 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 773 </text>
660 774 </svg>
661 775 </div>
662   - {interactive && onResizePointerDown
663   - ? ELEMENT_RESIZE_HANDLES.map((handle) => {
  776 + {interactive && !positionLocked && onDragPointerDown
  777 + ? resizeHandles.map((handle) => {
664 778 const left = x + w * handle.cx - ELEMENT_RESIZE_HANDLE_HIT / 2;
665 779 const top = y + h * handle.cy - ELEMENT_RESIZE_HANDLE_HIT / 2;
666 780 const inset = (ELEMENT_RESIZE_HANDLE_HIT - ELEMENT_RESIZE_HANDLE_SIZE) / 2;
... ... @@ -668,7 +782,7 @@ function ElementSelectionFrame({
668 782 <div
669 783 key={handle.id}
670 784 data-element-resize-handle="true"
671   - className="absolute z-[41] touch-none"
  785 + className="absolute z-[52] touch-none"
672 786 style={{
673 787 left,
674 788 top,
... ... @@ -685,12 +799,14 @@ function ElementSelectionFrame({
685 799 onClick={(e) => e.stopPropagation()}
686 800 >
687 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 803 style={{
690 804 left: inset,
691 805 top: inset,
692 806 width: ELEMENT_RESIZE_HANDLE_SIZE,
693 807 height: ELEMENT_RESIZE_HANDLE_SIZE,
  808 + backgroundColor: ELEMENT_RESIZE_HANDLE_FILL,
  809 + boxShadow: ELEMENT_RESIZE_HANDLE_SHADOW,
694 810 }}
695 811 />
696 812 </div>
... ... @@ -1021,39 +1137,6 @@ function formatMultipleOptionsCanvasLine(
1021 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 1140 function formatDateByPreset(format: string, date: Date): string {
1058 1141 const yyyy = String(date.getFullYear());
1059 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 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 1295 /** 根据元素类型与 config 渲染画布上的默认内容 */
1129 1296 function ElementContent({
1130 1297 el,
... ... @@ -1141,15 +1308,31 @@ function ElementContent({
1141 1308 }) {
1142 1309 const cfg = el.config as Record<string, unknown>;
1143 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 1328 const resolvedFontFamily = resolveLabelEditorElementFontFamily(cfg);
  1329 +
1149 1330 const commonStyle: React.CSSProperties = {
1150 1331 fontSize: (cfg?.fontSize as number) ?? 14,
1151 1332 fontFamily: resolvedFontFamily,
1152   - fontWeight: (cfg?.fontWeight as string) ?? 'normal',
  1333 + fontWeight: readFontWeight(cfg),
  1334 + fontStyle: readFontStyle(cfg),
  1335 + textDecoration: readTextDecoration(cfg),
1153 1336 textAlign: (cfg?.textAlign as any) ?? 'left',
1154 1337 color: inverted ? INVERT_COLORS_FG : ((cfg?.color as string) ?? '#000'),
1155 1338 backgroundColor: inverted ? INVERT_COLORS_BG : undefined,
... ... @@ -1160,31 +1343,19 @@ function ElementContent({
1160 1343 ? 'border-gray-300 bg-transparent'
1161 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 1347 const inputType = cfg?.inputType as string | undefined;
1178 1348 if (type === 'TEXT_STATIC' && isCompanyAutoElement(el) && !isAppPrintField) {
1179 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 1357 {previewText}
1187   - </div>
  1358 + </AlignedTextBox>,
1188 1359 );
1189 1360 }
1190 1361 if (type === 'TEXT_STATIC') {
... ... @@ -1196,29 +1367,32 @@ function ElementContent({
1196 1367 : [];
1197 1368 const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
1198 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 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 1377 muted && !inverted && 'text-gray-400',
1204 1378 muted && inverted && 'text-gray-300',
1205 1379 )}
1206   - style={{ ...commonStyle, ...textRotationStyle }}
1207   - title={line}
1208 1380 >
1209 1381 {line}
1210   - </div>
  1382 + </AlignedTextBox>,
1211 1383 );
1212 1384 }
1213 1385 const display =
1214 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 1394 {display}
1221   - </div>
  1395 + </AlignedTextBox>,
1222 1396 );
1223 1397 }
1224 1398 if (isAppPrintField) {
... ... @@ -1227,33 +1401,33 @@ function ElementContent({
1227 1401 ? (cfg.selectedOptionValues as string[])
1228 1402 : [];
1229 1403 const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
1230   - return (
  1404 + return wrapVertical(
1231 1405 <div
1232 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 1408 title="Filled in mobile app when printing"
1235 1409 >
1236 1410 {line}
1237   - </div>
  1411 + </div>,
1238 1412 );
1239 1413 }
1240 1414 const display =
1241 1415 inputType === 'number' ? ((cfg?.text as string) ?? '0') : text;
1242   - return (
  1416 + return wrapVertical(
1243 1417 <div
1244 1418 className={cn(
1245 1419 'w-full h-full px-1 flex items-center overflow-hidden pointer-events-none italic text-[11px]',
1246 1420 !inverted && 'text-gray-600',
1247 1421 )}
1248   - style={{ ...commonStyle, ...textRotationStyle }}
  1422 + style={{ ...commonStyle }}
1249 1423 title="Filled in mobile app when printing"
1250 1424 >
1251 1425 {display}
1252   - </div>
  1426 + </div>,
1253 1427 );
1254 1428 }
1255 1429 if (inputType === 'number') {
1256   - return (
  1430 + return wrapVertical(
1257 1431 <input
1258 1432 type="number"
1259 1433 readOnly
... ... @@ -1262,8 +1436,8 @@ function ElementContent({
1262 1436 'w-full h-full min-w-0 border rounded px-1 pointer-events-none',
1263 1437 invertedInputClass,
1264 1438 )}
1265   - style={{ ...commonStyle, ...textRotationStyle, textAlign: 'right' }}
1266   - />
  1439 + style={{ ...commonStyle, textAlign: 'right' }}
  1440 + />,
1267 1441 );
1268 1442 }
1269 1443 if (inputType === 'options') {
... ... @@ -1273,22 +1447,23 @@ function ElementContent({
1273 1447 : [];
1274 1448 const line = formatMultipleOptionsCanvasLine(cfg, text, selected);
1275 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 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 1457 muted && !inverted && 'text-gray-400',
1281 1458 muted && inverted && 'text-gray-300',
1282 1459 )}
1283   - style={{ ...commonStyle, ...textRotationStyle }}
1284   - title={line}
1285 1460 >
1286 1461 {line}
1287   - </div>
  1462 + </AlignedTextBox>,
1288 1463 );
1289 1464 }
1290 1465 if (inputType === 'text') {
1291   - return (
  1466 + return wrapVertical(
1292 1467 <input
1293 1468 type="text"
1294 1469 readOnly
... ... @@ -1297,48 +1472,38 @@ function ElementContent({
1297 1472 'w-full h-full min-w-0 border rounded px-1 pointer-events-none',
1298 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 1481 {text}
1310   - </div>
  1482 + </AlignedTextBox>,
1311 1483 );
1312 1484 }
1313 1485 if (type === 'TEXT_PRODUCT') {
1314 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 1497 {text}
1321   - </div>
  1498 + </AlignedTextBox>,
1322 1499 );
1323 1500 }
1324 1501 if (type === 'TEXT_PRICE') {
1325 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 1505 <span>{text}</span>
1341   - </div>
  1506 + </AlignedTextBox>,
1342 1507 );
1343 1508 }
1344 1509  
... ... @@ -1347,7 +1512,7 @@ function ElementContent({
1347 1512 const data = (cfg?.data as string) ?? '123456789';
1348 1513 const showText = (cfg?.showText as boolean) !== false;
1349 1514 const orientation = (
1350   - el.rotation === 'vertical' || (cfg?.orientation as string) === 'vertical'
  1515 + readElementRotation(el) === 'vertical' || (cfg?.orientation as string) === 'vertical'
1351 1516 ? 'vertical'
1352 1517 : 'horizontal'
1353 1518 ) as 'horizontal' | 'vertical';
... ... @@ -1382,31 +1547,29 @@ function ElementContent({
1382 1547 );
1383 1548 }
1384 1549  
1385   - // 图片/Logo
  1550 + // Image / Logo
1386 1551 if (type === 'IMAGE') {
1387 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 1558 if (src) {
1392   - return (
  1559 + return wrapVertical(
1393 1560 <div className="w-full h-full flex items-center justify-center overflow-hidden">
1394 1561 <img
1395 1562 src={resolvePictureUrlForDisplay(src)}
1396 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 1578 const previewFmtRaw = cfg?.__previewFormatted;
1416 1579 if (typeof previewFmtRaw === 'string') {
1417 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 1591 const it = String(cfg?.inputType ?? cfg?.InputType ?? '').toLowerCase();
... ... @@ -1448,7 +1613,7 @@ function ElementContent({
1448 1613 <div className="w-full h-full flex items-center justify-center overflow-hidden">
1449 1614 <div
1450 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 1617 title={`Format: ${format}`}
1453 1618 >
1454 1619 {format}
... ... @@ -1463,17 +1628,15 @@ function ElementContent({
1463 1628 readOnly
1464 1629 value="2025-02-01"
1465 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 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 1644 if (type === 'TIME') {
1482 1645 const previewTime = cfg?.__previewFormatted;
1483 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 1657 const d = new Date();
1493 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 1666 if (type === 'DURATION') {
1504 1667 const previewDur = cfg?.__previewFormatted;
1505 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 1679 const rawFormat =
... ... @@ -1521,12 +1686,10 @@ function ElementContent({
1521 1686 const rawV = cfg?.durationValue ?? cfg?.value ?? cfg?.offsetDays ?? cfg?.DurationValue ?? cfg?.Value ?? cfg?.OffsetDays;
1522 1687 const durationValue = Number.isFinite(Number(rawV)) ? Number(rawV) : 3;
1523 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 1714 const weightTextAlignRaw = String(cfg?.textAlign ?? cfg?.TextAlign ?? 'left').toLowerCase();
1552 1715 const weightTextAlign: 'left' | 'center' | 'right' =
1553 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 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 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 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 1740 return (
1638 1741 <div className="w-full h-full flex items-center justify-center overflow-hidden">
... ... @@ -1655,7 +1758,6 @@ function ElementContent({
1655 1758 );
1656 1759 }
1657 1760  
1658   - // 空白占位:预印刷 Logo/Image 区域,预览区展示占位字样
1659 1761 if (type === 'BLANK') {
1660 1762 const fontSize = Math.max(11, Math.min(el.width * 0.2, el.height * 0.36, 56));
1661 1763 return (
... ... @@ -1770,6 +1872,12 @@ export function LabelCanvas({
1770 1872 move: (e: PointerEvent) => void;
1771 1873 up: (e: PointerEvent) => void;
1772 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 1881 const paperResizeRef = useRef<{
1774 1882 edge: PaperResizeEdge;
1775 1883 startX: number;
... ... @@ -1784,6 +1892,7 @@ export function LabelCanvas({
1784 1892 const [isSpacePressed, setIsSpacePressed] = React.useState(false);
1785 1893 const [isPanning, setIsPanning] = React.useState(false);
1786 1894 const [paperResizeCursor, setPaperResizeCursor] = React.useState<string | null>(null);
  1895 + const [availableElementsOpen, setAvailableElementsOpen] = React.useState(false);
1787 1896 const panStartRef = useRef<{ x: number; y: number; scrollLeft: number; scrollTop: number } | null>(null);
1788 1897 const [panOffset, setPanOffset] = React.useState({ x: 0, y: 0 });
1789 1898 const panOffsetStartRef = useRef<{ x: number; y: number; startX: number; startY: number } | null>(null);
... ... @@ -1814,12 +1923,7 @@ export function LabelCanvas({
1814 1923 const showGrid = template.showGrid !== false;
1815 1924 const isRotatedPrint = printOrientation === 'horizontal';
1816 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 1928 const innerAvailW = Math.max(0, scrollViewport.width - RULER_W);
1825 1929 const innerAvailH = Math.max(0, scrollViewport.height - RULER_H);
... ... @@ -1893,24 +1997,165 @@ export function LabelCanvas({
1893 1997 if (!selectedId) return null;
1894 1998 const el = template.elements.find((x) => x.id === selectedId);
1895 1999 if (!el) return null;
1896   - return mergeLabelElementLivePatch(el, liveElementPatch);
  2000 + const merged = mergeLabelElementLivePatch(el, liveElementPatch);
  2001 + return displayElementGeometry(merged);
1897 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 2140 if (isSpacePressed || e.button === 1) return;
1904 2141  
1905 2142 e.stopPropagation();
1906 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 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 2160 const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
1916 2161 if (!local) return;
... ... @@ -1924,29 +2169,48 @@ export function LabelCanvas({
1924 2169 );
1925 2170  
1926 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 2175 startLocalX: local.x,
1931 2176 startLocalY: local.y,
1932   - w: el.width,
1933   - h: el.height,
  2177 + w: canonical.width,
  2178 + h: canonical.height,
1934 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 2214 const beginPaperResize = useCallback((e: React.PointerEvent, edge: PaperResizeEdge) => {
1951 2215 e.stopPropagation();
1952 2216 paperResizeRef.current = {
... ... @@ -1963,15 +2227,6 @@ export function LabelCanvas({
1963 2227 (e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId);
1964 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 2230 const applyElementResizeAtClient = useCallback(
1976 2231 (clientX: number, clientY: number) => {
1977 2232 const session = resizeRef.current;
... ... @@ -2026,27 +2281,44 @@ export function LabelCanvas({
2026 2281 }
2027 2282 detachResizeDocumentListeners();
2028 2283 if (lastUpdateRef.current) {
  2284 + const el0 = template.elements.find((x) => x.id === lastUpdateRef.current!.id);
2029 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 2296 lastUpdateRef.current = null;
2033 2297 resizeRef.current = null;
2034 2298 onLiveElementPatchChange?.(null);
2035 2299 suppressCanvasClickDeselectRef.current = true;
2036   - }, [detachResizeDocumentListeners, onUpdateElement, onLiveElementPatchChange]);
  2300 + }, [detachResizeDocumentListeners, onUpdateElement, onLiveElementPatchChange, template.elements]);
2037 2301  
2038 2302 const beginElementResize = useCallback(
2039 2303 (e: React.PointerEvent, elId: string, handleId: string) => {
2040 2304 e.stopPropagation();
2041 2305 e.preventDefault();
2042 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 2314 detachResizeDocumentListeners();
2046 2315  
2047 2316 const el0 = template.elements.find((x) => x.id === elId);
2048 2317 if (!el0) return;
  2318 + if (readElementPositionLocked(el0)) return;
2049 2319  
  2320 + const live = mergeLabelElementLivePatch(el0, liveElementPatchRef.current);
  2321 + const canonical = displayElementGeometry(live);
2050 2322 const local = clientToCanvasLocalPoint(e.clientX, e.clientY, canvasRef.current, scale);
2051 2323 if (!local) return;
2052 2324 resizeRef.current = {
... ... @@ -2054,17 +2326,17 @@ export function LabelCanvas({
2054 2326 corner: handleId,
2055 2327 startLocalX: local.x,
2056 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 2334 onLiveElementPatchChange?.({
2063 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 2342 const onMove = (ev: PointerEvent) => {
... ... @@ -2114,57 +2386,9 @@ export function LabelCanvas({
2114 2386 scrollContainerRef.current.scrollTop = panStartRef.current.scrollTop - dy;
2115 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 2394 // Resize Element(document 级监听处理缩放,此处跳过)
... ... @@ -2298,7 +2522,6 @@ export function LabelCanvas({
2298 2522 }
2299 2523  
2300 2524 const activeId = dragRef.current?.id || resizeRef.current?.id;
2301   - const dragWasActive = dragRef.current?.active ?? false;
2302 2525 if (activeId) {
2303 2526 const domEl = document.getElementById(`element-${activeId}`);
2304 2527 if (domEl) {
... ... @@ -2317,17 +2540,19 @@ export function LabelCanvas({
2317 2540 }
2318 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 2556 detachResizeDocumentListeners();
2332 2557 dragRef.current = null;
2333 2558 resizeRef.current = null;
... ... @@ -2335,10 +2560,13 @@ export function LabelCanvas({
2335 2560 setPaperResizeCursor(null);
2336 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 2571 useEffect(() => {
2344 2572 const onKeyDown = (e: KeyboardEvent) => {
... ... @@ -2395,6 +2623,7 @@ export function LabelCanvas({
2395 2623  
2396 2624 const el = template.elements.find(x => x.id === selectedId);
2397 2625 if (!el) return;
  2626 + if (readElementPositionLocked(el)) return;
2398 2627  
2399 2628 // allow typing in inputs without triggering move?
2400 2629 // Actually our elements are not inputs (unless we implement inline edit).
... ... @@ -2429,6 +2658,15 @@ export function LabelCanvas({
2429 2658  
2430 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 2670 const canvasClick = () => onSelect(null);
2433 2671  
2434 2672 // 容器的 Pan 处理
... ... @@ -2476,7 +2714,21 @@ export function LabelCanvas({
2476 2714 >
2477 2715 {/* Label Preview 标题 + 网格/预览/缩放 */}
2478 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 2732 <div className="flex flex-nowrap items-center justify-end gap-2 shrink-0 min-w-0">
2481 2733 {onPrintOrientationChange ? (
2482 2734 <PrintOrientationToggle
... ... @@ -2609,6 +2861,18 @@ export function LabelCanvas({
2609 2861 onPointerUp={handleContainerPointerUp}
2610 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 2876 <div
2613 2877 className="pointer-events-none absolute inset-0 z-0 bg-gray-100"
2614 2878 aria-hidden
... ... @@ -2689,7 +2953,7 @@ export function LabelCanvas({
2689 2953 className={cn(
2690 2954 'absolute left-0 top-0 shadow-lg outline-none',
2691 2955 isRotatedPrint ? 'overflow-visible' : 'overflow-hidden',
2692   - canvasBorderClass,
  2956 + templatePaperBorder.className,
2693 2957 isPanning ? 'cursor-grabbing' : 'cursor-grab'
2694 2958 )}
2695 2959 style={{
... ... @@ -2699,6 +2963,7 @@ export function LabelCanvas({
2699 2963 transformOrigin: 'top left',
2700 2964 pointerEvents: isSpacePressed ? 'none' : 'auto',
2701 2965 cursor: paperResizeCursor ?? undefined,
  2966 + ...templatePaperBorder.style,
2702 2967 }}
2703 2968 onClick={(e) => {
2704 2969 if (suppressCanvasClickDeselectRef.current) {
... ... @@ -2708,13 +2973,14 @@ export function LabelCanvas({
2708 2973 // 点击画布空白处取消选中
2709 2974 const target = e.target as HTMLElement;
2710 2975 const isOnElement = target.closest('[id^="element-"]');
  2976 + const isOnDragOverlay = target.closest('[data-element-drag-overlay="true"]');
2711 2977 const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]');
2712 2978 const isOnPaperResize =
2713 2979 target.closest('[data-paper-resize-handle="true"]') ||
2714 2980 target.closest('[title*="Drag to resize paper"]') ||
2715 2981 target.closest('[title*="Drag to increase paper height"]') ||
2716 2982 target.closest('[title*="Drag to increase paper width"]');
2717   - if (!isOnElement && !isOnResizeHandle && !isOnPaperResize) {
  2983 + if (!isOnElement && !isOnDragOverlay && !isOnResizeHandle && !isOnPaperResize) {
2718 2984 onSelect(null);
2719 2985 }
2720 2986 }}
... ... @@ -2722,6 +2988,7 @@ export function LabelCanvas({
2722 2988 // 空白处按下即开始平移(在画布内且未点到元素/纸张拖拽条)
2723 2989 const target = e.target as HTMLElement;
2724 2990 const isOnElement = target.closest('[id^="element-"]');
  2991 + const isOnDragOverlay = target.closest('[data-element-drag-overlay="true"]');
2725 2992 const isOnResizeHandle = target.closest('[data-element-resize-handle="true"]');
2726 2993 const isOnPaperResize =
2727 2994 target.closest('[data-paper-resize-handle="true"]') ||
... ... @@ -2729,7 +2996,7 @@ export function LabelCanvas({
2729 2996 target.closest('[title*="Drag to increase paper height"]') ||
2730 2997 target.closest('[title*="Drag to increase paper width"]');
2731 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 3000 if (isSpacePressed || e.button === 1) {
2734 3001 e.preventDefault();
2735 3002 e.stopPropagation();
... ... @@ -2778,27 +3045,28 @@ export function LabelCanvas({
2778 3045 {selectedElementLive
2779 3046 ? (() => {
2780 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 3050 return (
2783 3051 <>
2784 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 3055 aria-hidden
2788 3056 />
2789 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 3060 aria-hidden
2793 3061 />
2794 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 3065 aria-hidden
2798 3066 />
2799 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 3070 aria-hidden
2803 3071 />
2804 3072 </>
... ... @@ -2806,15 +3074,19 @@ export function LabelCanvas({
2806 3074 })()
2807 3075 : null}
2808 3076 {template.elements.map((el) => {
2809   - const effectiveEl = mergeLabelElementLivePatch(el, liveElementPatch);
  3077 + const merged = mergeLabelElementLivePatch(el, liveElementPatch);
  3078 + const effectiveEl = displayElementGeometry(merged);
2810 3079 const isPrintField = isPrintInputElement(el);
2811 3080 const isSelected = selectedId === el.id;
  3081 + const positionLocked = readElementPositionLocked(el);
  3082 + const isVerticalRotation = readElementRotation(effectiveEl) === 'vertical';
2812 3083 return (
2813 3084 <div
2814 3085 key={el.id}
2815 3086 id={`element-${el.id}`}
2816 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 3090 el.border === 'line' && 'border border-gray-400',
2819 3091 el.border === 'dotted' && 'border border-dotted border-gray-400',
2820 3092 isSelected && 'z-10',
... ... @@ -2829,18 +3101,32 @@ export function LabelCanvas({
2829 3101 e.stopPropagation();
2830 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 3110 <div
2835 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 3114 isPrintField &&
2838 3115 !isSelected &&
2839 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 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 3130 </div>
2845 3131 );
2846 3132 })}
... ... @@ -2855,6 +3141,8 @@ export function LabelCanvas({
2855 3141 displayUnit={previewRulerUnit}
2856 3142 printOrientation={printOrientation}
2857 3143 interactive
  3144 + positionLocked={readElementPositionLocked(selectedElementLive)}
  3145 + onDragPointerDown={(e) => beginElementDrag(e, selectedElementLive.id)}
2858 3146 onResizePointerDown={(e, handleId) =>
2859 3147 beginElementResize(e, selectedElementLive.id, handleId)
2860 3148 }
... ... @@ -2905,12 +3193,7 @@ export function LabelPreviewOnly({
2905 3193 const displayW = baseW * scaleToFit;
2906 3194 const displayH = baseH * scaleToFit;
2907 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 3197 // 与编辑区一致:内层 baseW×baseH,transformOrigin 0 0 缩放,保证位置/样式一致
2915 3198 return (
2916 3199 <div
... ... @@ -2918,8 +3201,8 @@ export function LabelPreviewOnly({
2918 3201 style={{ minWidth: displayW + 32 }}
2919 3202 >
2920 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 3207 <div
2925 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 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 3 import { Input } from '../../ui/input';
4 4 import { Button } from '../../ui/button';
5 5 import { Label } from '../../ui/label';
... ... @@ -26,7 +26,11 @@ import {
26 26 isBlankSpaceElement,
27 27 isCompanyAutoElement,
28 28 isTemplateSectionPersistedType,
  29 + elementEditorDisplayName,
  30 + readElementPositionLocked,
29 31 NUTRITION_FIXED_ITEMS,
  32 + LABEL_EDITOR_FONT_OPTIONS,
  33 + readLabelEditorFontFamilyChoice,
30 34 } from '../../../types/labelTemplate';
31 35 import {
32 36 buildCompanyPrintFieldsConfigPatch,
... ... @@ -36,6 +40,7 @@ import {
36 40 } from '../../../utils/companyPrintFields';
37 41 import { cn } from '../../ui/utils';
38 42 import { readInvertColors, isTextLikeElementForInvertColors } from '../../../utils/invertColorsConfig';
  43 +import { readVerticalAlign, readElementRotation, readFontWeight, readFontStyle, readTextDecoration } from '../../../utils/textElementLayout';
39 44 import {
40 45 type PreviewRulerDisplayUnit,
41 46 displayLengthToElementPx,
... ... @@ -45,10 +50,20 @@ import {
45 50 } from '../../../utils/previewRulerUnits';
46 51 import { unitToPx } from '../../../utils/labelTemplateUnits';
47 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 59 import type { LabelMultipleOptionDto } from '../../../types/labelMultipleOption';
49 60 import { getLabelMultipleOptions } from '../../../services/labelMultipleOptionService';
50 61 import { Checkbox } from '../../ui/checkbox';
51   -import { Trash2 } from 'lucide-react';
  62 +import {
  63 + NutritionManualEntryForm,
  64 + applyNutritionValuesToElementConfig,
  65 + nutritionValuesFromElementConfig,
  66 +} from '../NutritionManualEntryForm';
52 67 import {
53 68 BARCODE_FORMAT_OPTIONS,
54 69 DEFAULT_BARCODE_FORMAT,
... ... @@ -111,6 +126,11 @@ export function PropertiesPanel({
111 126 void readOnlyTemplateCode;
112 127 if (selectedElement) {
113 128 const isBlankElement = isBlankSpaceElement(selectedElement);
  129 + const elementDisplayName = elementEditorDisplayName(
  130 + selectedElement,
  131 + template.elements ?? [],
  132 + );
  133 + const positionLocked = readElementPositionLocked(selectedElement);
114 134 return (
115 135 <div className="flex h-full min-h-0 w-full min-w-0 flex-col border-l border-gray-200 bg-white">
116 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 144 <Input
125 145 type="number"
126 146 value={selectedElement.x}
  147 + disabled={positionLocked}
127 148 onChange={(e) =>
128 149 onElementChange(selectedElement.id, {
129 150 x: Number(e.target.value) || 0,
... ... @@ -137,6 +158,7 @@ export function PropertiesPanel({
137 158 <Input
138 159 type="number"
139 160 value={selectedElement.y}
  161 + disabled={positionLocked}
140 162 onChange={(e) =>
141 163 onElementChange(selectedElement.id, {
142 164 y: Number(e.target.value) || 0,
... ... @@ -146,6 +168,11 @@ export function PropertiesPanel({
146 168 />
147 169 </div>
148 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 176 <div className="grid grid-cols-2 gap-2">
150 177 <ElementDimensionField
151 178 label="Width"
... ... @@ -153,6 +180,7 @@ export function PropertiesPanel({
153 180 paperSizeTemplate={template.width}
154 181 templateUnit={template.unit}
155 182 displayUnit={previewRulerUnit}
  183 + disabled={positionLocked}
156 184 onPxChange={(width) =>
157 185 onElementChange(selectedElement.id, {
158 186 width: Math.max(1, width),
... ... @@ -165,6 +193,7 @@ export function PropertiesPanel({
165 193 paperSizeTemplate={template.height}
166 194 templateUnit={template.unit}
167 195 displayUnit={previewRulerUnit}
  196 + disabled={positionLocked}
168 197 onPxChange={(height) =>
169 198 onElementChange(selectedElement.id, {
170 199 height: Math.max(1, height),
... ... @@ -181,9 +210,11 @@ export function PropertiesPanel({
181 210 <div>
182 211 <Label className="text-xs">Rotation</Label>
183 212 <Select
184   - value={selectedElement.rotation}
  213 + value={readElementRotation(selectedElement)}
185 214 onValueChange={(v: Rotation) =>
186   - onElementChange(selectedElement.id, { rotation: v })
  215 + onElementChange(selectedElement.id, {
  216 + rotation: v,
  217 + })
187 218 }
188 219 >
189 220 <SelectTrigger className="h-8 text-sm">
... ... @@ -230,18 +261,11 @@ export function PropertiesPanel({
230 261 <div>
231 262 <Label className="text-xs">Element name</Label>
232 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 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 269 </div>
246 270 <ElementConfigFields
247 271 element={selectedElement}
... ... @@ -501,6 +525,7 @@ function ElementDimensionField({
501 525 templateUnit,
502 526 displayUnit,
503 527 onPxChange,
  528 + disabled = false,
504 529 }: {
505 530 label: string;
506 531 pxValue: number;
... ... @@ -508,6 +533,7 @@ function ElementDimensionField({
508 533 templateUnit: Unit;
509 534 displayUnit: PreviewRulerDisplayUnit;
510 535 onPxChange: (px: number) => void;
  536 + disabled?: boolean;
511 537 }) {
512 538 const basePaperPx = unitToPx(Number(paperSizeTemplate) || 0, templateUnit);
513 539 const displayValue = elementPxToDisplayLength(
... ... @@ -527,6 +553,7 @@ function ElementDimensionField({
527 553 type="number"
528 554 step={displayUnit === 'mm' ? 1 : displayUnit === 'inch' ? 0.0001 : 0.001}
529 555 value={formatPreviewRulerDisplayValue(displayValue, displayUnit)}
  556 + disabled={disabled}
530 557 onChange={(e) => {
531 558 const nextDisplay = Number(e.target.value);
532 559 if (!Number.isFinite(nextDisplay)) return;
... ... @@ -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 706 function TextStaticStyleFields({
568 707 cfg,
569 708 update,
570 709 textAlignDefault,
571 710 primaryTextLabel,
  711 + primaryTextHint,
572 712 hidePrimaryText = false,
573 713 }: {
574 714 cfg: Record<string, unknown>;
... ... @@ -576,6 +716,8 @@ function TextStaticStyleFields({
576 716 textAlignDefault: string;
577 717 /** Template 面板静态文案在属性里称 Value,其它分组仍用 Text */
578 718 primaryTextLabel?: 'Text' | 'Value';
  719 + /** Value/Text 输入框下方提示(如 Price 演示值说明) */
  720 + primaryTextHint?: string;
579 721 /** Auto-generated 控件:文案由系统自动填充,隐藏 Text/Value 输入 */
580 722 hidePrimaryText?: boolean;
581 723 }) {
... ... @@ -590,8 +732,12 @@ function TextStaticStyleFields({
590 732 onChange={(e) => update('text', e.target.value)}
591 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 738 </div>
594 739 ) : null}
  740 + <FontFamilyField cfg={cfg} update={update} />
595 741 <div>
596 742 <Label className="text-xs">Font Size</Label>
597 743 <Input
... ... @@ -617,6 +763,8 @@ function TextStaticStyleFields({
617 763 </SelectContent>
618 764 </Select>
619 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 877 />
730 878 );
731 879 case 'TEXT_PRODUCT':
  880 + return (
  881 + <TextStaticStyleFields
  882 + cfg={cfg}
  883 + update={update}
  884 + textAlignDefault="right"
  885 + hidePrimaryText={hidePrimaryText}
  886 + />
  887 + );
732 888 case 'TEXT_PRICE':
733 889 return (
734 890 <TextStaticStyleFields
735 891 cfg={cfg}
736 892 update={update}
737 893 textAlignDefault="right"
  894 + primaryTextLabel="Value"
  895 + primaryTextHint="Value entered here for demo only."
738 896 hidePrimaryText={hidePrimaryText}
739 897 />
740 898 );
... ... @@ -830,15 +988,13 @@ function ElementConfigFields({
830 988 value={src}
831 989 onChange={(url) => update('src', url)}
832 990 uploadSubDir="label-template-editor"
833   - oneImageOnly
834 991 boxSizePx={TEMPLATE_IMAGE_UPLOAD_SIZE_PX}
835   - hint="Stored in template; print uses this URL (empty if cleared)."
836 992 />
837 993 </div>
838 994 <div>
839 995 <Label className="text-xs">Scale Mode</Label>
840 996 <Select
841   - value={(cfg.scaleMode as string) ?? 'contain'}
  997 + value={readImageScaleMode(cfg)}
842 998 onValueChange={(v) => update('scaleMode', v)}
843 999 >
844 1000 <SelectTrigger className="h-8 text-sm mt-1">
... ... @@ -868,7 +1024,7 @@ function ElementConfigFields({
868 1024 <div>
869 1025 <Label className="text-xs">Scale Mode</Label>
870 1026 <Select
871   - value={(cfg.scaleMode as string) ?? 'contain'}
  1027 + value={readImageScaleMode(cfg)}
872 1028 onValueChange={(v) => update('scaleMode', v)}
873 1029 >
874 1030 <SelectTrigger className="h-8 text-sm mt-1">
... ... @@ -918,6 +1074,7 @@ function ElementConfigFields({
918 1074 </p>
919 1075 ) : null}
920 1076 </div>
  1077 + <FontFamilyField cfg={cfg} update={update} />
921 1078 <div>
922 1079 <Label className="text-xs">Font Size</Label>
923 1080 <Input
... ... @@ -943,6 +1100,8 @@ function ElementConfigFields({
943 1100 </SelectContent>
944 1101 </Select>
945 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 1112 <Label className="text-xs">Format</Label>
954 1113 <Input value="HH:mm" className="h-8 text-sm mt-1" readOnly />
955 1114 </div>
  1115 + <FontFamilyField cfg={cfg} update={update} />
956 1116 <div>
957 1117 <Label className="text-xs">Font Size</Label>
958 1118 <Input
... ... @@ -978,6 +1138,8 @@ function ElementConfigFields({
978 1138 </SelectContent>
979 1139 </Select>
980 1140 </div>
  1141 + <VerticalAlignField cfg={cfg} update={update} />
  1142 + <BiuStyleFields cfg={cfg} update={update} />
981 1143 </>
982 1144 );
983 1145 case 'DURATION':
... ... @@ -1001,6 +1163,7 @@ function ElementConfigFields({
1001 1163 </SelectContent>
1002 1164 </Select>
1003 1165 </div>
  1166 + <FontFamilyField cfg={cfg} update={update} />
1004 1167 <div>
1005 1168 <Label className="text-xs">Font Size</Label>
1006 1169 <Input
... ... @@ -1026,6 +1189,8 @@ function ElementConfigFields({
1026 1189 </SelectContent>
1027 1190 </Select>
1028 1191 </div>
  1192 + <VerticalAlignField cfg={cfg} update={update} />
  1193 + <BiuStyleFields cfg={cfg} update={update} />
1029 1194 </>
1030 1195 );
1031 1196 case 'WEIGHT':
... ... @@ -1033,9 +1198,28 @@ function ElementConfigFields({
1033 1198 const weightUnit = normalizeWeightUnit(cfgPickStr(cfg, ['unit', 'Unit'], 'g'));
1034 1199 const textAlign = cfgPickStr(cfg, ['textAlign', 'TextAlign'], 'left');
1035 1200 const fontSize = cfgPickNum(cfg, ['fontSize', 'FontSize'], 14);
  1201 + const weightInputMode = readWeightInputMode(cfg);
1036 1202 return (
1037 1203 <>
1038 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 1223 <Label className="text-xs">Value</Label>
1040 1224 <Input
1041 1225 type="number"
... ... @@ -1043,6 +1227,9 @@ function ElementConfigFields({
1043 1227 onChange={(e) => update('value', Number(e.target.value) || 0)}
1044 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 1233 </div>
1047 1234 <div>
1048 1235 <Label className="text-xs">Unit</Label>
... ... @@ -1062,6 +1249,7 @@ function ElementConfigFields({
1062 1249 </SelectContent>
1063 1250 </Select>
1064 1251 </div>
  1252 + <FontFamilyField cfg={cfg} update={update} />
1065 1253 <div>
1066 1254 <Label className="text-xs">Font Size</Label>
1067 1255 <Input
... ... @@ -1087,6 +1275,8 @@ function ElementConfigFields({
1087 1275 </SelectContent>
1088 1276 </Select>
1089 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 1315 case 'NUTRITION':
1126 1316 {
1127 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 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 1362 const fixedRows = NUTRITION_FIXED_ITEMS.map((item) => {
  1363 + const baseRow = baseFixed.find((r) => String(r.key ?? '').trim() === item.key);
1130 1364 const unit =
1131 1365 nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '');
1132 1366 return {
1133 1367 key: item.key,
1134 1368 label: item.label,
1135   - value: '',
  1369 + value: String(baseRow?.value ?? cfg[item.key] ?? ''),
1136 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 1377 const keyPatch: Record<string, unknown> = { fixedNutrients: fixedRows };
1140 1378 for (const item of NUTRITION_FIXED_ITEMS) {
1141   - keyPatch[item.key] = '';
1142 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 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 1387 const addExtraNutrient = () => {
... ... @@ -1196,20 +1432,50 @@ function ElementConfigFields({
1196 1432 />
1197 1433 </div>
1198 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 1455 </div>
1203 1456 <div>
1204 1457 <Label className="text-xs">Nutrition table structure</Label>
1205 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 1460 <span>Name</span>
1208 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 1475 <span />
1210 1476 </div>
1211 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 1479 <span className="text-xs text-gray-600">{item.label}</span>
1214 1480 <Input
1215 1481 value={nutritionFixedField(cfg, item.key, 'unit') || (item.defaultUnit ?? '')}
... ... @@ -1217,11 +1483,18 @@ function ElementConfigFields({
1217 1483 className="h-8 text-sm"
1218 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 1493 <span />
1221 1494 </div>
1222 1495 ))}
1223 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 1498 <Input
1226 1499 value={row.name}
1227 1500 onChange={(e) => updateExtraNutrient(row.id, 'name', e.target.value)}
... ... @@ -1234,6 +1507,15 @@ function ElementConfigFields({
1234 1507 className="h-8 text-sm"
1235 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 1519 <Button
1238 1520 type="button"
1239 1521 variant="ghost"
... ... @@ -1255,7 +1537,7 @@ function ElementConfigFields({
1255 1537 Add Nutrient
1256 1538 </Button>
1257 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 1541 </div>
1260 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 35 valueSourceTypeForLibraryCategory,
36 36 } from '../../../types/labelTemplate';
37 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 53 import type { PreviewRulerDisplayUnit } from '@/utils/previewRulerUnits';
40 54 import { PropertiesPanel } from './PropertiesPanel';
41 55 import { createLabelTemplate, getLabelTemplate, getLabelTemplates, updateLabelTemplate } from '../../../services/labelTemplateService';
... ... @@ -72,6 +86,12 @@ function buildCopiedTemplateId(sourceId: string): string {
72 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 95 function cloneStarterTemplate(source: LabelTemplate): LabelTemplate {
76 96 return {
77 97 ...source,
... ... @@ -103,7 +123,7 @@ export function LabelTemplateEditor({
103 123 if (initialTemplate) {
104 124 return {
105 125 ...initialTemplate,
106   - elements: sanitizeNutritionElementsForTemplateEditor(initialTemplate.elements),
  126 + elements: normalizeEditorElements(initialTemplate.elements),
107 127 };
108 128 }
109 129 const next = createDefaultTemplate(templateId ?? undefined);
... ... @@ -118,6 +138,7 @@ export function LabelTemplateEditor({
118 138 liveElementPatchRef.current = liveElementPatch;
119 139 const [scale, setScale] = useState(DEFAULT_SCALE);
120 140 const [previewOpen, setPreviewOpen] = useState(false);
  141 + const [instructionOpen, setInstructionOpen] = useState(false);
121 142 const [previewRulerUnit, setPreviewRulerUnit] = useState<PreviewRulerDisplayUnit>('cm');
122 143 const [starterOptions, setStarterOptions] = useState<Array<{ code: string; name: string }>>([]);
123 144 const [selectedStarterCode, setSelectedStarterCode] = useState<string>('');
... ... @@ -138,6 +159,26 @@ export function LabelTemplateEditor({
138 159 return mergeLabelElementLivePatch(el, liveElementPatch);
139 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 182 const previewTemplate = useMemo(
142 183 () => ({
143 184 ...template,
... ... @@ -308,7 +349,7 @@ export function LabelTemplateEditor({
308 349 printOrientation: normalizePrintOrientation(
309 350 apiTemplate.printOrientation ?? (apiTemplate as Record<string, unknown>).PrintOrientation,
310 351 ),
311   - elements: sanitizeNutritionElementsForTemplateEditor(
  352 + elements: normalizeEditorElements(
312 353 (apiTemplate.elements ?? []).map((raw, idx) => {
313 354 const el = raw as LabelElement;
314 355 const en = (el.elementName ?? '').trim();
... ... @@ -370,13 +411,26 @@ export function LabelTemplateEditor({
370 411 ...prev,
371 412 elements: prev.elements.map((el) => {
372 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 423 const geomTouched =
375 424 patch.x !== undefined ||
376 425 patch.y !== undefined ||
377 426 patch.width !== undefined ||
378   - patch.height !== undefined;
  427 + patch.height !== undefined ||
  428 + patch.rotation !== undefined;
379 429 if (!geomTouched) return merged;
  430 + // 竖排且宽>高:先纠正宽高再 clamp,避免 x 被钳到最左边
  431 + if (readElementRotation(merged) === 'vertical' && merged.width > merged.height) {
  432 + merged = canonicalElementGeometry(merged);
  433 + }
380 434 const c = clampLabelElementBox(
381 435 merged.x,
382 436 merged.y,
... ... @@ -387,7 +441,13 @@ export function LabelTemplateEditor({
387 441 undefined,
388 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 456 const flushLiveElementPatch = useCallback(() => {
397 457 const patch = liveElementPatchRef.current;
398 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 467 setLiveElementPatch(null);
409   - }, [updateElement]);
  468 + }, [template.elements, updateElement]);
410 469  
411 470 const handleSelectElement = useCallback((id: string | null) => {
412 471 flushLiveElementPatch();
... ... @@ -701,6 +760,10 @@ export function LabelTemplateEditor({
701 760 </Button>
702 761 </div>
703 762  
  763 + {instructionOpen ? (
  764 + <ElementsPanelInstructionPage onClose={() => setInstructionOpen(false)} />
  765 + ) : (
  766 + <>
704 767 {/* 模板配置区:统一 input-group 样式 */}
705 768 <div className="shrink-0 border-b border-[#cfd9ea] bg-[#dde7f5] px-4 py-2">
706 769 <div className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-2">
... ... @@ -915,7 +978,10 @@ export function LabelTemplateEditor({
915 978 boxSizing: "border-box",
916 979 }}
917 980 >
918   - <ElementsPanel onAddElement={addElement} />
  981 + <ElementsPanel
  982 + onAddElement={addElement}
  983 + onOpenInstruction={() => setInstructionOpen(true)}
  984 + />
919 985 </div>
920 986 <div
921 987 className="rounded-lg border border-[#c2d1e8] bg-[#e4ecf8] p-1"
... ... @@ -961,32 +1027,20 @@ export function LabelTemplateEditor({
961 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 1040 </div>
989 1041 </div>
  1042 + </>
  1043 + )}
990 1044 <Dialog open={previewOpen} onOpenChange={setPreviewOpen}>
991 1045 <DialogContent className="max-w-[90vw] max-h-[90vh] p-0 overflow-hidden flex flex-col">
992 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 45 filterGroupsByCompany,
46 46 filterLocationsByCompanyAndRegion,
47 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 55 import type { GroupListItem } from '../../types/group';
50 56 import type { PartnerListItem } from '../../types/partner';
51 57 import { LabelTemplateEditor } from './LabelTemplateEditor';
... ... @@ -82,8 +88,13 @@ function templateListDisplayLocation(t: LabelTemplateDto, locations: LocationDto
82 88 return locationColumnText(t, locations);
83 89 }
84 90  
85   -/** 列表行:Contents ← 接口 items / contentItems(元素名称列表) */
  91 +/** 列表行:Contents ← 接口 items / contentItems(元素展示名) */
86 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 98 if (Array.isArray(t.contentItems) && t.contentItems.length > 0) {
88 99 return t.contentItems.map((x) => String(x).trim()).filter(Boolean);
89 100 }
... ... @@ -93,11 +104,6 @@ function templateListContentItems(t: LabelTemplateDto): string[] {
93 104 if (typeof t.items === "string" && t.items.trim()) {
94 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 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 67 import type { ProductLocationLinkDto } from "../../types/productLocation";
68 68 import { getProductLocations } from "../../services/productLocationService";
69 69 import { LabelTemplateDataEntryView } from "./LabelTemplateDataEntryView";
  70 +import { NutritionManualEntryForm } from "./NutritionManualEntryForm";
70 71 import { LabelPreviewOnly } from "./LabelTemplateEditor/LabelCanvas";
71 72 import {
72 73 appliedLocationToEditor,
... ... @@ -2517,7 +2518,7 @@ function CreateLabelDialog({
2517 2518 const templateScanLocked = isTemplateSectionBarcodeOrQrElement(el);
2518 2519 return (
2519 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 2522 {templateScanLocked ? (
2522 2523 <Input
2523 2524 className="h-10 w-full min-w-0 box-border bg-gray-50"
... ... @@ -2575,33 +2576,31 @@ function CreateLabelDialog({
2575 2576 onChange={(e) =>
2576 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 2582 </div>
2582 2583 );
2583 2584 })}
2584 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 2605 </div>
2607 2606 ) : null}
... ... @@ -3503,7 +3502,7 @@ function EditLabelDialog({
3503 3502 const templateScanLocked = isTemplateSectionBarcodeOrQrElement(el);
3504 3503 return (
3505 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 3506 {templateScanLocked ? (
3508 3507 <Input
3509 3508 className="h-10 w-full min-w-0 box-border bg-gray-50"
... ... @@ -3561,33 +3560,31 @@ function EditLabelDialog({
3561 3560 onChange={(e) =>
3562 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 3566 </div>
3568 3567 );
3569 3568 })}
3570 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 3589 </div>
3593 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 989 <TableCell className="border-r text-sm font-normal text-gray-900">
990 990 <div className="flex items-center gap-2 min-w-0">
991 991 {productVisual.mode !== "none" ? (
992   - <CategoryButtonVisualThumb visual={productVisual} size="sm" />
  992 + <CategoryButtonVisualThumb visual={productVisual} variant="productCategory" />
993 993 ) : (
994 994 <Package className="w-4 h-4 text-gray-400 shrink-0" />
995 995 )}
... ... @@ -1179,7 +1179,7 @@ export function ProductsView() {
1179 1179 {visual.mode === "none" ? (
1180 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 1184 </TableCell>
1185 1185 <TableCell
... ... @@ -2550,6 +2550,7 @@ function ProductCategoryFormDialog({
2550 2550 ) : null}
2551 2551  
2552 2552 <CategoryButtonAppearancePreview
  2553 + variant="productCategory"
2553 2554 apSel={apSel}
2554 2555 displayText={displayText}
2555 2556 buttonBgColor={buttonBgColor}
... ...
美国版/Food Labeling Management Platform/src/components/ui/category-button-appearance-preview.tsx
1 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 8 import { CategoryButtonVisualThumb } from "./category-button-visual-thumb";
4 9 import { Label } from "./label";
5 10  
6 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 18 export function CategoryButtonAppearancePreview({
  19 + variant = "labelCategory",
15 20 apSel,
16 21 displayText,
17 22 buttonBgColor,
18 23 imageUrl,
19 24 fallbackName,
20 25 }: {
  26 + /** 与 App 展示场景对齐:Label Category 侧栏 / Product Category 标题 */
  27 + variant?: AppCategoryThumbVariant;
21 28 apSel: AppearanceSelection;
22 29 displayText: string;
23 30 buttonBgColor: string;
... ... @@ -39,13 +46,9 @@ export function CategoryButtonAppearancePreview({
39 46 return (
40 47 <div className="space-y-2">
41 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 52 </div>
50 53 </div>
51 54 );
... ...
美国版/Food Labeling Management Platform/src/components/ui/category-button-visual-thumb.tsx
1 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 10 import { resolvePictureUrlForDisplay } from "../../services/imageUploadService";
4 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 30 const TEXT_WRAP_STYLE: CSSProperties = {
... ... @@ -23,8 +39,8 @@ const TEXT_WRAP_STYLE: CSSProperties = {
23 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 44 return {
29 45 width,
30 46 height,
... ... @@ -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 59 display: "flex",
43 60 alignItems: "center",
44 61 justifyContent: "center",
45 62 overflow: "hidden",
46   - padding: size === "preview" ? 4 : 2,
  63 + padding: pad,
  64 + borderRadius: radius,
47 65 ...extra,
48 66 });
49 67 }
50 68  
51   -/** 产品分类 / 产品列表:按 buttonAppearance + categoryPhotoUrl 渲染色块或图片 */
  69 +/** 产品分类 / 产品列表 / 表单预览:与 App 固定方框外框一致 */
52 70 export function CategoryButtonVisualThumb({
53 71 visual,
54   - size = "md",
  72 + variant = "labelCategory",
55 73 className,
56 74 }: {
57 75 visual: CategoryVisualRender;
58   - size?: Size;
  76 + variant?: AppCategoryThumbVariant;
59 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 81 if (visual.mode === "image") {
65 82 return (
66 83 <div
67 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 87 <img
71 88 src={resolvePictureUrlForDisplay(visual.imageUrl)}
... ... @@ -80,14 +97,13 @@ export function CategoryButtonVisualThumb({
80 97 return (
81 98 <div
82 99 className={cn("border border-gray-200 shadow-sm shrink-0", className)}
83   - style={textThumbShellStyle(size, {
84   - borderRadius: radius,
  100 + style={textThumbShellStyle(variant, {
85 101 backgroundColor: visual.bg,
86 102 color: visual.textColor || "#ffffff",
87 103 })}
88 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 107 </div>
92 108 );
93 109 }
... ... @@ -96,7 +112,7 @@ export function CategoryButtonVisualThumb({
96 112 return (
97 113 <div
98 114 className={cn("border border-gray-200 shadow-sm shrink-0", className)}
99   - style={thumbBoxStyle(size, {
  115 + style={thumbBoxStyle(variant, {
100 116 borderRadius: radius,
101 117 backgroundColor: visual.bg,
102 118 })}
... ... @@ -109,10 +125,10 @@ export function CategoryButtonVisualThumb({
109 125 return (
110 126 <div
111 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 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 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 425  
426 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 1 import type { LabelElement } from "../types/labelTemplate";
2 2 import { canonicalElementType, NUTRITION_FIXED_ITEMS } from "../types/labelTemplate";
  3 +import { NUTRITION_FACTS_LAYOUT_ROWS, DEFAULT_NUTRITION_FOOTER_NOTE } from "./nutritionFactsLayout";
3 4  
4 5 /** 批量表 / 与 elementId 拼接的字段名分隔(避免与普通 element id 冲突) */
5 6 export const NUTRITION_FIELD_COMPOSITE_SEP = "###nut###";
... ... @@ -11,6 +12,7 @@ export function nutritionCompositeFieldKey(nutritionElementId: string, subKey: s
11 12 export type NutritionManualFieldSpec = {
12 13 subKey: string;
13 14 columnLabel: string;
  15 + inputType?: "text" | "checkbox";
14 16 };
15 17  
16 18 function nutritionExtraRowsFromCfg(cfg: Record<string, unknown>): Array<{
... ... @@ -33,7 +35,7 @@ function nutritionExtraRowsFromCfg(cfg: Record&lt;string, unknown&gt;): Array&lt;{
33 35 }
34 36  
35 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 39 return hit?.label ?? key;
38 40 }
39 41  
... ... @@ -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 60 export function listNutritionManualFieldSpecs(el: LabelElement): NutritionManualFieldSpec[] {
59 61 if (canonicalElementType(el.type) !== "NUTRITION") return [];
60 62 const cfg = (el.config ?? {}) as Record<string, unknown>;
61 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 66 { subKey: "calories", columnLabel: "Calories" },
65 67 ];
66 68  
67 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 93 for (const ex of nutritionExtraRowsFromCfg(cfg)) {
84 94 const id = String(ex.id ?? "").trim();
85 95 if (!id) continue;
86 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 101 return specs;
90 102 }
91 103  
... ... @@ -98,7 +110,7 @@ export function nutritionManualValuesFromTemplateConfig(_el: LabelElement): Reco
98 110 const specs = listNutritionManualFieldSpecs(_el);
99 111 const out: Record<string, string> = {};
100 112 for (const s of specs) {
101   - out[s.subKey] = "";
  113 + out[s.subKey] = s.inputType === "checkbox" ? "false" : "";
102 114 }
103 115 return out;
104 116 }
... ... @@ -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 125 export function sanitizeNutritionTemplateConfig(
114 126 cfg: Record<string, unknown>,
... ... @@ -120,27 +132,36 @@ export function sanitizeNutritionTemplateConfig(
120 132 delete out.Calories;
121 133 delete out.ServingsPerContainer;
122 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 141 const baseFixed = Array.isArray(out.fixedNutrients)
125 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 145 const baseRow = baseFixed.find((r) => String(r.key ?? "").trim() === item.key);
129 146 const unit = String(baseRow?.unit ?? item.defaultUnit ?? "").trim();
  147 + const lessThan = Boolean(baseRow?.lessThan ?? out[`${item.key}LessThan`]);
130 148 return {
131 149 key: item.key,
132 150 label: String(baseRow?.label ?? item.label),
133 151 value: "",
134 152 unit,
  153 + dailyValuePercent: "",
  154 + lessThan,
135 155 };
136 156 });
137 157 out.fixedNutrients = fixedArr;
138 158  
139   - for (const item of NUTRITION_FIXED_ITEMS) {
  159 + for (const item of NUTRITION_FACTS_LAYOUT_ROWS) {
140 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 163 else delete out[`${item.key}Unit`];
  164 + out[`${item.key}LessThan`] = Boolean(row?.lessThan);
144 165 }
145 166  
146 167 out.extraNutrients = nutritionExtraRowsFromCfg(out).map((ex) => ({
... ... @@ -149,6 +170,10 @@ export function sanitizeNutritionTemplateConfig(
149 170 value: "",
150 171 unit: ex.unit,
151 172 }));
  173 +
  174 + if (!String(out.nutritionFooterNote ?? "").trim()) {
  175 + out.nutritionFooterNote = DEFAULT_NUTRITION_FOOTER_NOTE;
  176 + }
152 177 return out;
153 178 }
154 179  
... ... @@ -167,7 +192,6 @@ export function sanitizeNutritionElementsForTemplateEditor(
167 192  
168 193 /**
169 194 * 将手动录入合并进 NUTRITION 的 config(供画布预览;与 App 端 apply 逻辑字段一致)。
170   - * 输出中仅保留模板已声明的营养成分行,与 listNutritionManualFieldSpecs 一致。
171 195 */
172 196 export function mergeNutritionManualIntoConfig(
173 197 baseCfg: Record<string, unknown>,
... ... @@ -184,48 +208,48 @@ export function mergeNutritionManualIntoConfig(
184 208 delete cfg.calories;
185 209 delete cfg.Calories;
186 210 }
187   - } else {
188   - delete cfg.calories;
189   - delete cfg.Calories;
190 211 }
191 212  
192 213 if (specSubKeys.has("servingsPerContainer")) {
193 214 cfg.servingsPerContainer = pickManual(manual, "servingsPerContainer");
194   - } else {
195   - cfg.servingsPerContainer = "";
196   - delete cfg.ServingsPerContainer;
197 215 }
198   -
199 216 if (specSubKeys.has("servingSize")) {
200 217 cfg.servingSize = pickManual(manual, "servingSize");
201   - } else {
202   - cfg.servingSize = "";
203   - delete cfg.ServingSize;
204 218 }
205 219  
206 220 const baseFixed = Array.isArray(baseCfg.fixedNutrients)
207 221 ? (baseCfg.fixedNutrients as Record<string, unknown>[])
208 222 : [];
209 223 const fixedArr: Record<string, unknown>[] = [];
  224 +
210 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 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 254 cfg.fixedNutrients = fixedArr;
231 255  
... ... @@ -240,6 +264,8 @@ export function mergeNutritionManualIntoConfig(
240 264 value: pickManual(manual, s.subKey),
241 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 270 cfg.extraNutrients = newExtras;
245 271 return cfg;
... ...
美国版/Food Labeling Management Platform/src/main.tsx
... ... @@ -6,6 +6,7 @@
6 6 import "./styles/category-appearance-toggle.css";
7 7 import "./styles/form-dialog.css";
8 8 import "./styles/fonts.css";
  9 + import "./styles/label-editor-fonts.css";
9 10  
10 11 createRoot(document.getElementById("root")!).render(<App />);
11 12  
12 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 65 typeAdd: typeof typeAddRaw === "string" ? typeAddRaw.trim() : undefined,
66 66 inputKey: typeof ik === "string" ? ik : e.inputKey ?? null,
67 67 libraryCategory,
  68 + border: normalizeTemplateBorder(e.border ?? e.Border ?? e.BorderType ?? e.borderType),
68 69 config: stripLabelConfigPrefixes(rawCfg) as LabelElement["config"],
69 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 48 | 'NUTRITION';
49 49 export type ElementTypeValue = ElementType | string;
50 50  
  51 +import { NUTRITION_FACTS_LAYOUT_ROWS, DEFAULT_NUTRITION_FOOTER_NOTE } from '../lib/nutritionFactsLayout';
  52 +
51 53 export interface NutritionFixedItem {
52 54 key: string;
53 55 label: string;
... ... @@ -61,21 +63,14 @@ export interface NutritionExtraItem {
61 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 75 /** Left panel section titles (Elements panel); persisted per element as part of libraryCategory logic */
81 76 export type ElementLibraryCategory =
... ... @@ -190,6 +185,186 @@ export function isComposedLibraryCategoryValue(s: string): boolean {
190 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 369 * 旧模板无「面板英文名」时的兜底(同 type 多入口时可能不准,新模板以点击面板为准)。
195 370 */
... ... @@ -370,15 +545,57 @@ export const PRESET_LABEL_SIZES: { name: string; width: number; height: number;
370 545 /** 标签编辑器画布字体:与左侧 Elements 列表、整站 UI(fonts.css --font-sans)一致 */
371 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 595 export function resolveLabelEditorElementFontFamily(
375 596 cfg: Record<string, unknown> | null | undefined,
376 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 601 /** 按 type 创建默认元素(带默认 config) */
... ... @@ -388,7 +605,7 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl
388 605 const defaults: Record<ElementType, { width: number; height: number; config: Record<string, unknown> }> = {
389 606 TEXT_STATIC: { width: 120, height: 24, config: { text: 'Text', fontFamily: editorFont, fontSize: 14, fontWeight: 'normal', textAlign: 'left' } },
390 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 609 BARCODE: {
393 610 width: 160,
394 611 height: 48,
... ... @@ -414,24 +631,38 @@ export function createDefaultElement(type: ElementType, x = 20, y = 20): LabelEl
414 631 height: 24,
415 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 646 WEIGHT_PRICE: { width: 100, height: 24, config: { unitPrice: 10, weight: 0.5, currency: '$', fontFamily: editorFont, fontSize: 14, textAlign: 'left' } },
419 647 BLANK: { width: 80, height: 48, config: { fontFamily: editorFont } },
420 648 NUTRITION: {
421   - width: 200,
422   - height: 120,
  649 + width: 220,
  650 + height: 280,
423 651 config: {
424 652 fontFamily: editorFont,
425 653 nutritionTitleFontSize: 16,
  654 + nutritionFooterNote: DEFAULT_NUTRITION_FOOTER_NOTE,
426 655 servingsPerContainer: '',
427 656 servingSize: '',
428 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 660 key: item.key,
432 661 label: item.label,
433 662 value: '',
434 663 unit: item.defaultUnit ?? '',
  664 + dailyValuePercent: '',
  665 + lessThan: false,
435 666 })),
436 667 extraNutrients: [],
437 668 },
... ... @@ -902,24 +1133,27 @@ export function isDataEntryTableColumnElement(el: LabelElement): boolean {
902 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 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 1159 /** 与详情接口 elements 顺序一致(orderNum → zIndex → 原序) */
... ... @@ -1082,10 +1316,6 @@ const PERSISTED_TYPE_BY_GROUP_AND_LABEL: Record&lt;string, ElementType&gt; = {
1082 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 1319 export function canonicalElementType(raw: ElementTypeValue): ElementType {
1090 1320 const type = String(raw ?? "").trim();
1091 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 16 export const INVERT_COLORS_FG = "#ffffff";
17 17  
18 18 export function isTextLikeElementForInvertColors(type: string): boolean {
  19 + return isTextLikeElementForLayout(type);
  20 +}
  21 +
  22 +/** 支持水平/垂直对齐与 invert 的文本类控件 */
  23 +export function isTextLikeElementForLayout(type: string): boolean {
19 24 const t = String(type || "").toUpperCase();
20 25 return (
21 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 +]
... ...