Commit 2055477052aec2e620d1170221f889e7f2b436d3
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 "@dcloudio/uni-app"; |
| 3 | 3 | import { initOfflineSqlite } from "./utils/sqliteSync"; |
| 4 | 4 | import { syncNowAndRefreshCaches } from "./utils/offlineSyncManager"; |
| 5 | 5 | import { isLoggedIn } from "./utils/authSession"; |
| 6 | +import { preloadAllLabelEditorFonts } from "./utils/labelEditorFonts"; | |
| 6 | 7 | |
| 7 | 8 | let networkSyncInFlight = false; |
| 8 | 9 | |
| 9 | 10 | onLaunch(() => { |
| 10 | 11 | void initOfflineSqlite(); |
| 12 | + void preloadAllLabelEditorFonts(); | |
| 11 | 13 | uni.onNetworkStatusChange((res) => { |
| 12 | 14 | if (!res.isConnected || !isLoggedIn() || networkSyncInFlight) return; |
| 13 | 15 | networkSyncInFlight = true; | ... | ... |
美国版/Food Labeling Management App UniApp/src/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<Record<string, string[]>>({}) |
| 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 () => { |
| 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 () => { |
| 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 () => { |
| 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 ' |
| 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<string, any>): 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 '../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 'qrcode.react'; |
| 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): 'lb' | 'kg' | 'mg' | 'g' | 'oz' { |
| 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"><</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). < 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 "../../types/productCategory"; |
| 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<string, unknown>): Array<{ |
| 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<string, unknown>): 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<string, string>, 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<string, ElementType> = { |
| 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 <img> 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 = "#000000"; |
| 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 | +] | ... | ... |